diff --git a/pylabrobot/plate_washing/__init__.py b/pylabrobot/plate_washing/__init__.py new file mode 100644 index 00000000000..18197c8ed11 --- /dev/null +++ b/pylabrobot/plate_washing/__init__.py @@ -0,0 +1,7 @@ +"""Plate washing module for PyLabRobot. + +This module provides support for automated plate washers. +""" + +from .backend import PlateWasherBackend +from .plate_washer import PlateWasher diff --git a/pylabrobot/plate_washing/backend.py b/pylabrobot/plate_washing/backend.py new file mode 100644 index 00000000000..66ec81ffbd3 --- /dev/null +++ b/pylabrobot/plate_washing/backend.py @@ -0,0 +1,37 @@ +"""Abstract base class for plate washer backends. + +Plate washers are devices that automate the washing of microplates, +typically used in ELISA, cell-based assays, and other applications. +""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + +from pylabrobot.machines.backend import MachineBackend + + +class PlateWasherBackend(MachineBackend, metaclass=ABCMeta): + """Abstract base class for plate washer backends. + + Subclasses must implement setup() and stop() for hardware communication. + Device-specific operations (wash, prime, dispense, etc.) are exposed + directly on the backend rather than through a generic interface, since + each washer model has its own parameter set. + """ + + @abstractmethod + async def setup(self) -> None: + """Set up the plate washer. + + This should establish connection to the device and configure + communication parameters. + """ + + @abstractmethod + async def stop(self) -> None: + """Stop the plate washer and close connections. + + This should safely close all connections and ensure the device + is in a safe state. + """ diff --git a/pylabrobot/plate_washing/biotek/__init__.py b/pylabrobot/plate_washing/biotek/__init__.py new file mode 100644 index 00000000000..dc8718dd159 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/__init__.py @@ -0,0 +1,5 @@ +"""BioTek plate washer backends for PyLabRobot. + +Import device-specific symbols from subpackages: + from pylabrobot.plate_washing.biotek.el406 import BioTekEL406Backend +""" diff --git a/pylabrobot/plate_washing/biotek/el406/__init__.py b/pylabrobot/plate_washing/biotek/el406/__init__.py new file mode 100644 index 00000000000..84fc450a271 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/__init__.py @@ -0,0 +1,36 @@ +"""BioTek EL406 plate washer backend.""" + +from .actions import EL406ActionsMixin +from .backend import BioTekEL406Backend +from .communication import EL406CommunicationMixin +from .constants import ( + ACK_BYTE, + DEFAULT_READ_TIMEOUT, + LONG_READ_TIMEOUT, + VALID_BUFFERS, + VALID_SYRINGES, +) +from .enums import ( + EL406Motor, + EL406MotorHomeType, + EL406PlateType, + EL406Sensor, + EL406StepType, + EL406SyringeManifold, + EL406WasherManifold, +) +from .errors import EL406CommunicationError, EL406DeviceError +from .helpers import ( + encode_column_mask, + encode_signed_byte, + encode_volume_16bit, + syringe_to_byte, + validate_buffer, + validate_flow_rate, + validate_plate_type, + validate_syringe, + validate_volume, +) +from .protocol import build_framed_message +from .queries import EL406QueriesMixin +from .steps import EL406StepsMixin diff --git a/pylabrobot/plate_washing/biotek/el406/actions.py b/pylabrobot/plate_washing/biotek/el406/actions.py new file mode 100644 index 00000000000..792919df8f3 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/actions.py @@ -0,0 +1,210 @@ +"""EL406 action and control methods. + +This module contains the mixin class for action/control operations on the +BioTek EL406 plate washer (reset, home, pause, resume, etc.). +""" + +from __future__ import annotations + +import logging + +from .constants import ( + ABORT_COMMAND, + END_OF_BATCH_COMMAND, + HOME_VERIFY_MOTORS_COMMAND, + LONG_READ_TIMEOUT, + PAUSE_COMMAND, + RESET_COMMAND, + RESUME_COMMAND, + SET_WASHER_MANIFOLD_COMMAND, + VACUUM_PUMP_CONTROL_COMMAND, +) +from .enums import ( + EL406Motor, + EL406MotorHomeType, + EL406StepType, + EL406WasherManifold, +) +from .protocol import build_framed_message + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class EL406ActionsMixin: + """Mixin providing action/control methods for the EL406. + + This mixin provides: + - Abort, pause, resume operations + - Reset instrument + - Home/verify motors + - Vacuum pump control + - End-of-batch operations + - Auto-prime operations + - Set washer manifold + + Requires: + self._send_framed_command: Async method for sending framed commands + self._send_action_command: Async method for sending action commands + """ + + async def _send_framed_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + raise NotImplementedError + + async def _send_action_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + raise NotImplementedError + + async def abort( + self, + step_type: EL406StepType | None = None, + ) -> None: + """Abort a running operation. + + Args: + step_type: Optional step type to abort. If None, aborts current operation. + + Raises: + RuntimeError: If device not initialized. + TimeoutError: If timeout waiting for ACK response. + """ + logger.info( + "Aborting %s", + f"step type {step_type.name}" if step_type is not None else "current operation", + ) + + step_type_value = step_type.value if step_type is not None else 0 + data = bytes([step_type_value]) + framed_command = build_framed_message(ABORT_COMMAND, data) + await self._send_framed_command(framed_command) + + async def pause(self) -> None: + """Pause a running operation.""" + logger.info("Pausing operation") + framed_command = build_framed_message(PAUSE_COMMAND) + await self._send_framed_command(framed_command) + + async def resume(self) -> None: + """Resume a paused operation.""" + logger.info("Resuming operation") + framed_command = build_framed_message(RESUME_COMMAND) + await self._send_framed_command(framed_command) + + async def reset(self) -> None: + """Reset the instrument to a known state.""" + logger.info("Resetting instrument") + framed_command = build_framed_message(RESET_COMMAND) + await self._send_action_command(framed_command, timeout=LONG_READ_TIMEOUT) + logger.info("Instrument reset complete") + + async def _perform_end_of_batch(self) -> None: + """Perform end-of-batch activities - sends completion marker. + + NOTE: This command (140) is just a completion marker and does NOT: + - Stop the pump + - Home the syringes + + For a complete cleanup after a protocol, use cleanup_after_protocol() instead, + or manually call: + 1. set_vacuum_pump(False) - to stop the pump + 2. home_motors() - to return syringes to home position + """ + logger.info("Performing end-of-batch activities (completion marker)") + framed_command = build_framed_message(END_OF_BATCH_COMMAND) + await self._send_action_command(framed_command, timeout=60.0) + logger.info("End-of-batch marker sent") + + async def cleanup_after_protocol(self) -> None: + """Complete cleanup after running a protocol. + + This method performs the full cleanup sequence that the original BioTek + software does after all protocol steps complete: + 1. Stop the vacuum/peristaltic pump + 2. Home the syringes (XYZ motors) + 3. Send end-of-batch completion marker + + This is the recommended way to end a protocol run. + + Example: + >>> # Run protocol steps + >>> await backend.syringe_prime("A", 1000, 5, 2) + >>> await backend.syringe_prime("B", 1000, 5, 2) + >>> # Then cleanup + >>> await backend.cleanup_after_protocol() + """ + logger.info("Starting post-protocol cleanup") + + # Step 1: Stop the pump + logger.info(" Stopping vacuum pump...") + await self.set_vacuum_pump(False) + + # Step 2: Home syringes + logger.info(" Homing motors...") + await self.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) + + # Step 3: Send end-of-batch marker + logger.info(" Sending end-of-batch marker...") + await self._perform_end_of_batch() + + logger.info("Post-protocol cleanup complete") + + async def set_vacuum_pump(self, enabled: bool) -> None: + """Control the vacuum/peristaltic pump on or off. + + This sends command 299 (LeaveVacuumPumpOn) to control the pump state. + After syringe_prime or other pump operations, call this with + enabled=False to stop the pump. + + Args: + enabled: True to turn pump ON, False to turn pump OFF. + + Raises: + RuntimeError: If device not initialized. + TimeoutError: If timeout waiting for response. + + Example: + >>> # After syringe prime, stop the pump + >>> await backend.syringe_prime("A", 1000, 5, 2) + >>> await backend.set_vacuum_pump(False) # STOP THE PUMP + >>> await backend.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) + """ + state_str = "ON" if enabled else "OFF" + logger.info("Setting vacuum pump: %s", state_str) + + # Command 299 with 2-byte parameter (little-endian short): 1=on, 0=off + data = bytes([1 if enabled else 0, 0x00]) + framed_command = build_framed_message(VACUUM_PUMP_CONTROL_COMMAND, data) + await self._send_framed_command(framed_command) + logger.info("Vacuum pump set to %s", state_str) + + async def home_motors( + self, + home_type: EL406MotorHomeType, + motor: EL406Motor | None = None, + ) -> None: + """Home or verify motor positions.""" + logger.info( + "Home/verify motors: type=%s, motor=%s", + home_type.name, + motor.name if motor is not None else "default(0)", + ) + + motor_num = motor.value if motor is not None else 0 + data = bytes([home_type.value, motor_num]) + framed_command = build_framed_message(HOME_VERIFY_MOTORS_COMMAND, data) + await self._send_action_command(framed_command, timeout=120.0) + logger.info("Motors homed") + + async def set_washer_manifold(self, manifold: EL406WasherManifold) -> None: + """Set the washer manifold type.""" + logger.info("Setting washer manifold to: %s", manifold.name) + data = bytes([manifold.value]) + framed_command = build_framed_message(SET_WASHER_MANIFOLD_COMMAND, data) + await self._send_framed_command(framed_command) + logger.info("Washer manifold set to: %s", manifold.name) diff --git a/pylabrobot/plate_washing/biotek/el406/actions_tests.py b/pylabrobot/plate_washing/biotek/el406/actions_tests.py new file mode 100644 index 00000000000..b61392c9f8a --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/actions_tests.py @@ -0,0 +1,255 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Action methods. + +This module contains tests for Action methods. +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, + EL406Motor, + EL406MotorHomeType, + EL406StepType, + EL406WasherManifold, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendAbort(unittest.IsolatedAsyncioTestCase): + """Test EL406 abort functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_abort_command_byte(self): + """Abort command should be 0x89 in framed message.""" + await self.backend.abort() + last_header = self.backend.io.written_data[-2] + self.assertEqual(last_header[2], 0x89) + + async def test_abort_without_step_type_uses_zero(self): + """Abort without step_type should default to 0 (abort current).""" + await self.backend.abort() + last_data = self.backend.io.written_data[-1] + self.assertEqual(last_data[0], 0) + + async def test_abort_with_step_type_sends_step_value(self): + """Abort with step_type should send the step type value.""" + await self.backend.abort(step_type=EL406StepType.M_WASH) + last_data = self.backend.io.written_data[-1] + self.assertEqual(last_data[0], EL406StepType.M_WASH.value) + + async def test_abort_raises_when_device_not_initialized(self): + """Abort should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.abort() + + +class TestEL406BackendPause(unittest.IsolatedAsyncioTestCase): + """Test EL406 pause functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_pause_command_byte(self): + """Pause command should be 0x8A in framed message.""" + await self.backend.pause() + last_command = self.backend.io.written_data[-1] + self.assertEqual(last_command[2], 0x8A) + + async def test_pause_raises_when_device_not_initialized(self): + """Pause should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.pause() + + async def test_pause_raises_on_timeout(self): + """Pause should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") + with self.assertRaises(TimeoutError): + await self.backend.pause() + + +class TestEL406BackendResume(unittest.IsolatedAsyncioTestCase): + """Test EL406 resume functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_resume_command_byte(self): + """Resume command should be 0x8B in framed message.""" + await self.backend.resume() + last_command = self.backend.io.written_data[-1] + self.assertEqual(last_command[2], 0x8B) + + async def test_resume_raises_when_device_not_initialized(self): + """Resume should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.resume() + + async def test_resume_raises_on_timeout(self): + """Resume should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") + with self.assertRaises(TimeoutError): + await self.backend.resume() + + +class TestEL406BackendReset(unittest.IsolatedAsyncioTestCase): + """Test EL406 reset functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_reset_command_byte(self): + """Reset command should be 0x70 in framed message.""" + await self.backend.reset() + last_command = self.backend.io.written_data[-1] + self.assertEqual(last_command[2], 0x70) + + async def test_reset_raises_when_device_not_initialized(self): + """Reset should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.reset() + + async def test_reset_raises_on_timeout(self): + """Reset should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") + with self.assertRaises(TimeoutError): + await self.backend.reset() + + +class TestEL406BackendHomeMotors(unittest.IsolatedAsyncioTestCase): + """Test EL406 motor homing functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_home_motors_sends_command(self): + """home_motors should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.home_motors( + home_type=EL406MotorHomeType.HOME_MOTOR, + motor=EL406Motor.CARRIER_X, + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_home_motors_raises_when_device_not_initialized(self): + """home_motors should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.home_motors(home_type=EL406MotorHomeType.HOME_XYZ_MOTORS) + + +class TestRunSelfCheck(unittest.IsolatedAsyncioTestCase): + """Test run_self_check functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_run_self_check_has_success_key(self): + """run_self_check result should have a success key.""" + self.backend.io.set_read_buffer(b"\x00\x06") + result = await self.backend.run_self_check() + self.assertIn("success", result) + + async def test_run_self_check_success_on_valid_response(self): + """run_self_check should report success when device responds OK.""" + self.backend.io.set_read_buffer(b"\x00\x06") + result = await self.backend.run_self_check() + self.assertTrue(result["success"]) + + async def test_run_self_check_failure_on_error_response(self): + """run_self_check should report failure on error response.""" + self.backend.io.set_read_buffer(b"\x01\x06") + result = await self.backend.run_self_check() + self.assertFalse(result["success"]) + + async def test_run_self_check_raises_when_device_not_initialized(self): + """run_self_check should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.run_self_check() + + +class TestSetWasherManifold(unittest.IsolatedAsyncioTestCase): + """Test set_washer_manifold functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_set_washer_manifold_sends_correct_command_byte(self): + """set_washer_manifold should send command byte 0xD9 in framed message.""" + await self.backend.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL) + last_header = self.backend.io.written_data[-2] + self.assertEqual(last_header[2], 0xD9) + + async def test_set_washer_manifold_includes_manifold_type(self): + """set_washer_manifold should include manifold type in command data.""" + await self.backend.set_washer_manifold(EL406WasherManifold.TUBE_192) + last_data = self.backend.io.written_data[-1] + self.assertEqual(last_data[0], EL406WasherManifold.TUBE_192.value) + + async def test_set_washer_manifold_accepts_all_types(self): + """set_washer_manifold should accept all manifold types.""" + for manifold in EL406WasherManifold: + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.set_washer_manifold(manifold) + + async def test_set_washer_manifold_raises_when_device_not_initialized(self): + """set_washer_manifold should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL) diff --git a/pylabrobot/plate_washing/biotek/el406/backend.py b/pylabrobot/plate_washing/biotek/el406/backend.py new file mode 100644 index 00000000000..3b8255dcf90 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/backend.py @@ -0,0 +1,167 @@ +"""BioTek EL406 plate washer backend. + +This module provides the backend implementation for the BioTek EL406 +plate washer, communicating via FTDI USB serial interface. + +Protocol Details: +- Serial: 38400 baud, 8 data bits, 2 stop bits, no parity +- Flow control: disabled (no flow control) +- ACK byte: 0x06 +- Commands are binary with little-endian encoding +- Read timeout: 15000ms, Write timeout: 5000ms +""" + +from __future__ import annotations + +import asyncio +import logging + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.plate_washing.backend import PlateWasherBackend + +from .actions import EL406ActionsMixin +from .communication import EL406CommunicationMixin +from .constants import ( + DEFAULT_READ_TIMEOUT, +) +from .enums import EL406PlateType +from .errors import EL406CommunicationError +from .helpers import validate_plate_type +from .queries import EL406QueriesMixin +from .steps import EL406StepsMixin + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class BioTekEL406Backend( + EL406CommunicationMixin, + EL406QueriesMixin, + EL406ActionsMixin, + EL406StepsMixin, + PlateWasherBackend, +): + """Backend for BioTek EL406 plate washer. + + Communicates with the EL406 via FTDI USB interface. + + Attributes: + timeout: Default timeout for operations in seconds. + plate_type: Currently configured plate type. + + Example: + >>> backend = BioTekEL406Backend() + >>> washer = PlateWasher( + ... name="el406", + ... size_x=200, size_y=200, size_z=100, + ... backend=backend + ... ) + >>> await washer.setup() + >>> await backend.peristaltic_prime(volume=300.0, flow_rate="High") + >>> await backend.manifold_wash(cycles=3) + """ + + def __init__( + self, + timeout: float = DEFAULT_READ_TIMEOUT, + plate_type: EL406PlateType = EL406PlateType.PLATE_96_WELL, + device_id: str | None = None, + ) -> None: + """Initialize the EL406 backend. + + Args: + timeout: Default timeout for operations in seconds. + plate_type: Plate type to use for operations. + device_id: FTDI device serial number for explicit connection. + """ + super().__init__() + self.timeout = timeout + self.plate_type = plate_type + self._device_id = device_id + self.io: FTDI | None = None + self._command_lock = asyncio.Lock() # Protect against concurrent commands + + async def setup(self) -> None: + """Set up communication with the EL406. + + Configures the FTDI USB interface with the correct parameters: + - 38400 baud + - 8 data bits, 2 stop bits, no parity (8N2) + - No flow control (disabled) + + If ``self.io`` is already set (e.g. injected mock for testing), + it is used as-is and ``setup()`` is not called on it again. + + Raises: + RuntimeError: If pylibftdi is not installed or communication fails. + """ + logger.info("BioTekEL406Backend setting up") + logger.info(" Timeout: %.1f seconds", self.timeout) + logger.info(" Plate type: %s", self.plate_type.name if self.plate_type else "not set") + + if self.io is None: + self.io = FTDI(device_id=self._device_id) + await self.io.setup() + + # Configure serial parameters + logger.debug("Configuring serial parameters...") + try: + await self.io.set_baudrate(38400) + await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + logger.info(" Serial: 38400 baud, 8N2") + + SIO_DISABLE_FLOW_CTRL = 0x0 + await self.io.set_flowctrl(SIO_DISABLE_FLOW_CTRL) + logger.info(" Flow control: NONE") + + await self.io.set_rts(True) + await self.io.set_dtr(True) + logger.debug(" RTS and DTR enabled") + except Exception as e: + await self.io.stop() + self.io = None + raise EL406CommunicationError( + f"Failed to configure FTDI device: {e}", + operation="configure", + original_error=e, + ) from e + + # Purge buffers + logger.debug("Purging TX/RX buffers...") + await self._purge_buffers() + + # Test communication + logger.info("Testing communication with device...") + try: + await self._test_communication() + logger.info(" Communication test: PASSED") + except Exception as e: + logger.error(" Communication test: FAILED - %s", e) + raise + + logger.info("BioTekEL406Backend setup complete") + + async def stop(self) -> None: + """Stop communication with the EL406.""" + logger.info("BioTekEL406Backend stopping") + if self.io is not None: + await self.io.stop() + self.io = None + + def set_plate_type(self, plate_type: EL406PlateType | int) -> None: + """Set the current plate type.""" + validated_type = validate_plate_type(plate_type) + self.plate_type = validated_type + logger.info("Plate type set to: %s", self.plate_type.name) + + def get_plate_type(self) -> EL406PlateType: + """Get the current plate type.""" + return self.plate_type + + def serialize(self) -> dict: + """Serialize backend configuration.""" + return { + **super().serialize(), + "timeout": self.timeout, + "plate_type": self.plate_type.value, + "device_id": self._device_id, + } diff --git a/pylabrobot/plate_washing/biotek/el406/communication.py b/pylabrobot/plate_washing/biotek/el406/communication.py new file mode 100644 index 00000000000..53aefc86eae --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/communication.py @@ -0,0 +1,551 @@ +"""EL406 low-level communication methods. + +This module contains the mixin class for low-level USB/FTDI communication +with the BioTek EL406 plate washer. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import TYPE_CHECKING, NamedTuple + +from .constants import ( + ACK_BYTE, + INIT_STATE_COMMAND, + LONG_READ_TIMEOUT, + NAK_BYTE, + START_STEP_COMMAND, + STATE_INITIAL, + STATE_PAUSED, + STATE_RUNNING, + STATE_STOPPED, + STATUS_POLL_COMMAND, + TEST_COMM_COMMAND, +) +from .enums import EL406PlateType +from .error_codes import get_error_message +from .errors import EL406CommunicationError, EL406DeviceError +from .protocol import build_framed_message + +if TYPE_CHECKING: + from pylabrobot.io.ftdi import FTDI + + +class DevicePollResult(NamedTuple): + """Parsed result from a STATUS_POLL response.""" + + validity: int + state: int + status: int + raw_response: bytes + + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class EL406CommunicationMixin: + """Mixin providing low-level communication methods for the EL406. + + This mixin provides: + - Buffer purging + - Framed command sending + - Action command sending (with completion wait) + - Framed query sending + - Low-level byte reading + + Requires: + self.io: FTDI IO wrapper instance + self.timeout: Default timeout in seconds + self._command_lock: asyncio.Lock for command serialization + """ + + io: FTDI | None + timeout: float + plate_type: EL406PlateType + _command_lock: asyncio.Lock + + async def _write_to_device(self, data: bytes) -> None: + """Write bytes to the FTDI device, wrapping errors. + + Raises: + EL406CommunicationError: If the write fails. + """ + assert self.io is not None + try: + await self.io.write(data) + except Exception as e: + raise EL406CommunicationError( + f"Failed to write to device: {e}. Device may have disconnected.", + operation="write", + original_error=e, + ) from e + + async def _wait_for_ack(self, timeout: float, t0: float) -> None: + """Poll device for ACK byte within the remaining timeout window. + + Args: + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.time()``). + + Raises: + RuntimeError: If device sends NAK. + TimeoutError: If no ACK within timeout. + """ + assert self.io is not None + while time.time() - t0 < timeout: + byte = await self.io.read(1) + if byte: + if byte[0] == NAK_BYTE: + raise RuntimeError( + f"Device rejected command (NAK). Response: {byte!r}. " + "This may indicate an invalid command, bad parameters, or device busy state." + ) + if byte[0] == ACK_BYTE: + return + await asyncio.sleep(0.01) + raise TimeoutError("Timeout waiting for ACK") + + async def _read_exact_bytes(self, count: int, timeout: float, t0: float) -> bytes: + """Read exactly *count* bytes from the device, polling until done or timeout. + + Args: + count: Number of bytes to read. + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.time()``). + + Returns: + Bytes read (may be shorter than *count* if timeout is reached). + """ + assert self.io is not None + buf = b"" + while len(buf) < count and time.time() - t0 < timeout: + chunk = await self.io.read(count - len(buf)) + if chunk: + buf += chunk + else: + await asyncio.sleep(0.01) + return buf + + async def _purge_buffers(self) -> None: + """Purge the RX and TX buffers.""" + if self.io is None: + return + + try: + for _ in range(6): + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + except Exception as e: + raise EL406CommunicationError( + f"Failed to purge FTDI buffers: {e}. Device may have disconnected.", + operation="purge", + original_error=e, + ) from e + + async def _test_communication(self) -> None: + """Test communication with the device. + + Sends framed command 0x73 (115) and expects ACK (0x06) response. + + Raises: + RuntimeError: If communication test fails. + """ + if self.io is None: + raise RuntimeError("EL406 communication test failed: device not open") + + try: + framed_command = build_framed_message(TEST_COMM_COMMAND) + response = await self._send_framed_command(framed_command, timeout=5.0) + if ACK_BYTE not in response: + raise RuntimeError( + f"EL406 communication test failed: expected ACK (0x06), got {response!r}" + ) + except TimeoutError as e: + raise RuntimeError(f"EL406 communication test failed: timeout - {e}") from e + + logger.info("EL406 communication test passed") + + # Send INIT_STATE (0xA0) command to clear device state + logger.info("Sending INIT_STATE command (0xA0) to clear device state") + init_state_cmd = build_framed_message(INIT_STATE_COMMAND) + init_response = await self._send_framed_command(init_state_cmd, timeout=5.0) + logger.debug("INIT_STATE sent, response: %s", init_response.hex()) + + async def start_batch(self) -> None: + """Send START_STEP command to begin a batch of step operations. + + Use this function at the beginning of a protocol, before executing any step + commands. This puts the device in "ready to execute steps" mode. Must be + called once before running step commands like prime, dispense, aspirate, + shake, etc. + + This should be called: + - After setup() completes + - Before running any step commands + - Only once per batch of operations (not before each individual step) + """ + if self.io is None: + raise RuntimeError("Device not initialized - call setup() first") + + logger.info("Sending START_STEP to begin batch operations") + + # Send initialization commands before START_STEP + pre_batch_commands = [0xBF, 0xC1, 0xF2, 0xF4, 0x0154, 0x0102, 0x010A] + for cmd in pre_batch_commands: + cmd_frame = build_framed_message(cmd) + try: + resp = await self._send_framed_command(cmd_frame, timeout=2.0) + logger.debug("Command 0x%04X response: %s", cmd, resp.hex()) + except Exception as e: + logger.warning("Pre-batch command 0x%04X failed: %s", cmd, e) + + # Data byte is the plate type value (e.g., 0x04 for 96-well, 0x01 for 384-well). + start_step_data = bytes([self.plate_type.value]) + start_step_cmd = build_framed_message(START_STEP_COMMAND, start_step_data) + response = await self._send_framed_command(start_step_cmd, timeout=5.0) + logger.debug("START_STEP sent, response: %s", response.hex()) + + async def _send_framed_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send a framed command and wait for full response. + + The device responds to framed commands with: + - ACK (0x06) + 11-byte header + N-byte data + + This method reads the complete response to avoid leaving data in the buffer. + For ACK-only commands (e.g. TEST_COMM, INIT_STATE), the header wait acts as + an implicit settling delay that the device needs before accepting further + commands. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds. + + Returns: + Complete response bytes (ACK + header + data). + + Raises: + TimeoutError: If timeout waiting for response. + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + async with self._command_lock: + await self._purge_buffers() + + # Send header and data separately + header = framed_message[:11] + data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(header) + logger.debug("Sent header: %s", header.hex()) + + if data: + await asyncio.sleep(0.001) # Small delay between header and data + await self._write_to_device(data) + logger.debug("Sent data: %s", data.hex()) + logger.debug("Sent framed: %s", framed_message.hex()) + + # Read full response: ACK + 11-byte header + variable data + await self._wait_for_ack(timeout, time.time()) + result = bytes([ACK_BYTE]) + + # Fresh timestamp after ACK — header + data share a single timeout budget. + t0 = time.time() + resp_header = await self._read_exact_bytes(11, timeout, t0) + + if len(resp_header) == 11: + result += resp_header + # Parse data length from header bytes 7-8 (little-endian) + data_len = resp_header[7] | (resp_header[8] << 8) + response_data = await self._read_exact_bytes(data_len, timeout, t0) + result += response_data + logger.debug("Full response: %s (%d bytes)", result.hex(), len(result)) + else: + logger.debug("ACK-only response (no frame): %s", result.hex()) + + return result + + async def _send_action_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send an action command and wait for completion frame. + + Action commands (like reset, home_motors) work differently from query commands: + 1. Send command + 2. Device sends ACK immediately (acknowledging receipt) + 3. Device performs the physical action (takes time) + 4. Device sends completion frame when done + + This method waits for both the ACK and the completion frame. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation including action completion. + + Returns: + Completion frame bytes (header + data). + + Raises: + TimeoutError: If timeout waiting for ACK or completion. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT # Default to long timeout for actions + + async with self._command_lock: + await self._purge_buffers() + await self._write_to_device(framed_message) + logger.debug("Sent action command: %s", framed_message.hex()) + + t0 = time.time() + + # Step 1: Wait for ACK (short timeout) + await self._wait_for_ack(5.0, t0) + logger.debug("Got ACK, waiting for completion...") + + # Step 2: Wait for completion frame (11-byte header + data) + header = await self._read_exact_bytes(11, timeout, t0) + if len(header) < 11: + raise TimeoutError(f"Timeout waiting for completion header (got {len(header)} bytes)") + + # Parse data length and read remaining data + data_len = header[7] | (header[8] << 8) + data = await self._read_exact_bytes(data_len, timeout, t0) + + result = header + data + + logger.debug("Completion frame: %s (%d bytes)", result.hex(), len(result)) + + # Parse and log result + cmd_echo = result[2] | (result[3] << 8) + response_data = result[11 : 11 + data_len] if len(result) >= 11 + data_len else b"" + logger.debug(" Command echo: 0x%04X, data: %s", cmd_echo, response_data.hex()) + + return result + + async def _send_framed_query( + self, + command: int, + data: bytes = b"", + timeout: float | None = None, + ) -> bytes: + """Send a framed query command and read full response with header and data. + + Sends the 11-byte header and optional data payload as separate USB writes, + then reads the full response: ACK + 11-byte response header + data. + + Args: + command: 16-bit command code + data: Optional data bytes to send with command + timeout: Timeout in seconds + + Returns: + Data bytes from response (header stripped). + + Raises: + RuntimeError: If device not initialized or response invalid. + TimeoutError: If timeout waiting for response. + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + framed_message = build_framed_message(command, data) + + async with self._command_lock: + await self._purge_buffers() + + # Split header and data + msg_header = framed_message[:11] + msg_data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(msg_header) + logger.debug("Sent query header 0x%04X: %s", command, msg_header.hex()) + + if msg_data: + await asyncio.sleep(0.001) + await self._write_to_device(msg_data) + logger.debug("Sent query data: %s", msg_data.hex()) + + # Wait for ACK + try: + await self._wait_for_ack(timeout, time.time()) + except RuntimeError as e: + raise RuntimeError( + f"Device rejected command 0x{command:04X} (NAK). " "Check command code and parameters." + ) from e + except TimeoutError as e: + raise TimeoutError(f"Timeout waiting for ACK (command 0x{command:04X})") from e + + t0 = time.time() + # Read 11-byte response header (shares timeout budget with data) + resp_header = await self._read_exact_bytes(11, timeout, t0) + if len(resp_header) < 11: + raise TimeoutError(f"Timeout reading response header (got {len(resp_header)}/11 bytes)") + + logger.debug("Response header: %s", resp_header.hex()) + + # Parse data length from header bytes 7-8 (little-endian) + data_len = resp_header[7] | (resp_header[8] << 8) + logger.debug("Response data length: %d", data_len) + + # Read data bytes + response_data = await self._read_exact_bytes(data_len, timeout, t0) + if len(response_data) < data_len: + raise TimeoutError( + f"Timeout reading response data (got {len(response_data)}/{data_len} bytes)" + ) + + logger.debug("Response data: %s", response_data.hex()) + return response_data + + async def _poll_device_state(self) -> DevicePollResult: + """Send one STATUS_POLL and return the parsed device state. + + Returns: + DevicePollResult with validity, state, status, and raw_response. + + Raises: + EL406CommunicationError: If poll response is too short to parse. + """ + poll_command = build_framed_message(STATUS_POLL_COMMAND) + poll_response = await self._send_framed_command(poll_command, timeout=2.0) + logger.debug("Status poll response (%d bytes): %s", len(poll_response), poll_response.hex()) + + if len(poll_response) < 21: + # Short response — return zeroed fields so callers can handle it + return DevicePollResult(validity=0, state=0, status=0, raw_response=poll_response) + + # Data layout (after ACK+header at offset 12): + # bytes 12-13: validity (little-endian, must be 0) + # bytes 14-15: state (little-endian) + # bytes 16-19: timestamp/counter + # byte 20: status code + validity = poll_response[12] | (poll_response[13] << 8) + state = poll_response[14] | (poll_response[15] << 8) + status = poll_response[20] + + if validity != 0: + error_msg = get_error_message(validity) + logger.warning("Status poll returned error 0x%04X (%d): %s", validity, validity, error_msg) + + logger.debug("Status poll: validity=%d, state=%d, status=%d", validity, state, status) + return DevicePollResult( + validity=validity, state=state, status=status, raw_response=poll_response + ) + + async def _wait_until_ready(self, timeout: float = 5.0, poll_interval: float = 0.1) -> None: + """Poll until the device is no longer in STATE_RUNNING. + + Args: + timeout: Maximum time to wait in seconds. + poll_interval: Time between polls in seconds. + + Raises: + TimeoutError: If the device stays busy beyond *timeout*. + """ + t0 = time.time() + while time.time() - t0 < timeout: + poll = await self._poll_device_state() + if poll.state != STATE_RUNNING: + return + await asyncio.sleep(poll_interval) + raise TimeoutError(f"Device still busy (STATE_RUNNING) after {timeout}s waiting for readiness") + + async def _send_step_command( + self, + framed_message: bytes, + timeout: float | None = None, + poll_interval: float = 0.1, + ) -> bytes: + """Send a step command and poll for completion. + + Step commands (prime, dispense, aspirate, shake, etc.) require polling + for completion using STATUS_POLL (0x92) until the operation completes. + + Protocol flow: + 1. Wait for device to be ready (not RUNNING) + 2. Send step command (e.g., SYRINGE_PRIME 0xA2) + 3. Device ACKs immediately + 4. Poll with STATUS_POLL (0x92) repeatedly + 5. Check state in response to determine completion + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation. + poll_interval: Time between status polls in seconds. + + Returns: + Final status response bytes. + + Raises: + TimeoutError: If timeout waiting for completion. + EL406DeviceError: If device reports an error during the step. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT + + logger.debug("Starting step command with timeout=%ss", timeout) + + # 1. Wait for device to be ready (not RUNNING) + await self._wait_until_ready(timeout=5.0) + + # 2. Send the step command + logger.debug("Sending step command: %s", framed_message.hex()) + response = await self._send_framed_command(framed_message, timeout=5.0) + logger.debug("Step command sent, got initial response: %s", response.hex()) + + # 3. Initial delay before polling + await asyncio.sleep(0.5) + + # 4. Poll for completion + t0 = time.time() + poll_count = 0 + + logger.debug("Starting polling loop...") + + while time.time() - t0 < timeout: + await asyncio.sleep(poll_interval) + poll_count += 1 + + poll = await self._poll_device_state() + logger.debug("Poll #%d: %d bytes", poll_count, len(poll.raw_response)) + + if poll.state in (STATE_INITIAL, STATE_STOPPED): + logger.debug("Step completed (state=%d) after %d polls", poll.state, poll_count) + if poll.validity != 0: + raise EL406DeviceError(poll.validity, get_error_message(poll.validity)) + return poll.raw_response + + if poll.state == STATE_RUNNING: + logger.debug("Step in progress (state=Running), continuing poll...") + elif poll.state == STATE_PAUSED: + logger.warning("Step is paused (state=3)") + elif poll.status == 0: + # Unknown state with status=0 means done + logger.debug("Done (unknown state=%d, status=0)", poll.state) + return poll.raw_response + else: + logger.debug("Unknown state=%d, status=%d, continuing...", poll.state, poll.status) + + raise TimeoutError(f"Timeout waiting for step completion after {timeout}s") diff --git a/pylabrobot/plate_washing/biotek/el406/communication_tests.py b/pylabrobot/plate_washing/biotek/el406/communication_tests.py new file mode 100644 index 00000000000..6391f91425b --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/communication_tests.py @@ -0,0 +1,36 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Communication and protocol functionality. + +This module contains tests for Communication and protocol functionality. +""" + +import unittest + +# Import the backend module (mock is already installed by test_el406_mock import) +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestTestCommunication(unittest.IsolatedAsyncioTestCase): + """Test communication verification. + + The _test_communication() method should send a query command + and verify the device responds with ACK (0x06). + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + # Don't call setup() yet - we want to test _test_communication() directly + + async def test_communication_sends_query_command(self): + """Test communication should send a query command.""" + self.backend.io = MockFTDI() + # _test_communication() sends two commands, need enough responses + self.backend.io.set_read_buffer(b"\x06" * 10) + + await self.backend._test_communication() + + # Verify some command was sent + self.assertGreater(len(self.backend.io.written_data), 0) diff --git a/pylabrobot/plate_washing/biotek/el406/constants.py b/pylabrobot/plate_washing/biotek/el406/constants.py new file mode 100644 index 00000000000..9f88ab7a775 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/constants.py @@ -0,0 +1,95 @@ +"""EL406 protocol constants and validation sets. + +This module contains all protocol constants, command codes, and validation +sets used by the BioTek EL406 plate washer backend. +""" + +from __future__ import annotations + +# Protocol constants +ACK_BYTE = 0x06 +NAK_BYTE = 0x15 # Negative acknowledgment - device rejected command + +# Control commands +ABORT_COMMAND = 0x89 # Command 137 +TEST_COMM_COMMAND = 0x73 # Command 115 +INIT_STATE_COMMAND = 0xA0 # Command 160 - clears device state +PAUSE_COMMAND = 0x8A # Command 138 +RESUME_COMMAND = 0x8B # Command 139 +START_STEP_COMMAND = 0x8D # Command 141 - sent before step commands +END_OF_BATCH_COMMAND = 0x8C # Command 140 +RESET_COMMAND = 0x70 # Command 112 +STATUS_POLL_COMMAND = 0x92 # Command 146 - poll for step completion + +# Query commands +GET_WASHER_MANIFOLD_COMMAND = 0xD8 # Command 216 +GET_SYRINGE_MANIFOLD_COMMAND = 0xBB # Command 187 +GET_SENSOR_ENABLED_COMMAND = 0xD2 # Command 210 +GET_SYRINGE_BOX_INFO_COMMAND = 0xF6 # Command 246 + +# 16-bit command codes +GET_SERIAL_NUMBER_COMMAND = 0x0100 # Command 256 +GET_PERISTALTIC_INSTALLED_COMMAND = 0x0104 # Command 260 + +# Action commands +HOME_VERIFY_MOTORS_COMMAND = 0xC8 # Command 200 +SET_WASHER_MANIFOLD_COMMAND = 0xD9 # Command 217 +RUN_SELF_CHECK_COMMAND = 0x95 # Command 149 +VACUUM_PUMP_CONTROL_COMMAND = 0x012B # Command 299 + +# Peristaltic pump commands +PERISTALTIC_DISPENSE_COMMAND = 0x8F # Command 143 +PERISTALTIC_PRIME_COMMAND = 0x90 # Command 144 +PERISTALTIC_PURGE_COMMAND = 0x91 # Command 145 + +# Syringe pump commands +SYRINGE_DISPENSE_COMMAND = 0xA1 # Command 161 +SYRINGE_PRIME_COMMAND = 0xA2 # Command 162 + +# Manifold commands +SHAKE_SOAK_COMMAND = 0xA3 # Command 163 +MANIFOLD_WASH_COMMAND = 0xA4 # Command 164 +MANIFOLD_ASPIRATE_COMMAND = 0xA5 # Command 165 +MANIFOLD_DISPENSE_COMMAND = 0xA6 # Command 166 +MANIFOLD_PRIME_COMMAND = 0xA7 # Command 167 +MANIFOLD_AUTO_CLEAN_COMMAND = 0xA8 # Command 168 + +# Timeout constants +DEFAULT_READ_TIMEOUT = 15.0 # seconds +LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) + +# Message framing constants +MSG_START_MARKER = 0x01 # first byte of header +MSG_VERSION_MARKER = 0x02 # second byte of header +MSG_CONSTANT = 0x01 # constant byte at position 4 +MSG_HEADER_SIZE = 11 # Total header size in bytes + +# Valid buffer valves +VALID_BUFFERS = {"A", "B", "C", "D"} + +# Valid syringe selections +VALID_SYRINGES = {"A", "B", "BOTH"} + +# Valid intensity levels +VALID_INTENSITIES = {"Slow", "Medium", "Fast", "Variable"} + +# Valid peristaltic flow rates +VALID_PERISTALTIC_FLOW_RATES = {"Low", "Medium", "High"} + +# Flow rate range +MIN_FLOW_RATE = 1 +MAX_FLOW_RATE = 9 + +# Syringe-specific limits +SYRINGE_MIN_FLOW_RATE = 1 +SYRINGE_MAX_FLOW_RATE = 5 +SYRINGE_MIN_VOLUME = 80 +SYRINGE_MAX_VOLUME = 9999 +SYRINGE_MAX_PUMP_DELAY = 5000 +SYRINGE_MAX_SUBMERGE_DURATION = 1439 # 23:59 in minutes + +# Device state enum (from status poll data positions 2-3) +STATE_INITIAL = 1 # Idle/ready +STATE_RUNNING = 2 # Busy +STATE_PAUSED = 3 # Paused +STATE_STOPPED = 4 # Stopped/ready diff --git a/pylabrobot/plate_washing/biotek/el406/enums.py b/pylabrobot/plate_washing/biotek/el406/enums.py new file mode 100644 index 00000000000..66583775d23 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/enums.py @@ -0,0 +1,101 @@ +"""EL406 enumeration types. + +This module contains all enumeration types used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + +import enum + + +class EL406PlateType(enum.IntEnum): + """Plate types supported by the EL406.""" + + PLATE_1536_WELL = 0 + PLATE_384_WELL = 1 + PLATE_384_PCR = 2 + PLATE_96_WELL = 4 + PLATE_1536_FLANGE = 14 + + +class EL406WasherManifold(enum.IntEnum): + """Washer manifold types.""" + + TUBE_96_DUAL = 0 + TUBE_192 = 1 + TUBE_128 = 2 + TUBE_96_SINGLE = 3 + DEEP_PIN_96 = 4 + NOT_INSTALLED = 255 + + +class EL406SyringeManifold(enum.IntEnum): + """Syringe manifold types.""" + + NOT_INSTALLED = 0 + TUBE_16 = 1 + TUBE_32_LARGE_BORE = 2 + TUBE_32_SMALL_BORE = 3 + TUBE_16_7 = 4 + TUBE_8 = 5 + PLATE_6_WELL = 6 + PLATE_12_WELL = 7 + PLATE_24_WELL = 8 + PLATE_48_WELL = 9 + + +class EL406Sensor(enum.IntEnum): + """Sensor types for the EL406.""" + + VACUUM = 0 # Vacuum sensor + WASTE = 1 # Waste container sensor + FLUID = 2 # Fluid level sensor + FLOW = 3 # Flow sensor + FILTER_VAC = 4 # Filter vacuum sensor + PLATE = 5 # Plate presence sensor + + +class EL406StepType(enum.IntEnum): + """Step types for EL406 operations.""" + + UNDEFINED = 0 + P_DISPENSE = 1 # Peristaltic pump dispense + P_PRIME = 2 # Peristaltic pump prime + P_PURGE = 3 # Peristaltic pump purge + S_DISPENSE = 4 # Syringe dispense + S_PRIME = 5 # Syringe prime + M_WASH = 6 # Manifold wash + M_ASPIRATE = 7 # Manifold aspirate + M_DISPENSE = 8 # Manifold dispense + M_PRIME = 9 # Manifold prime + M_AUTO_CLEAN = 10 # Manifold auto-clean + SHAKE_SOAK = 11 # Shake/soak + + +class EL406Motor(enum.IntEnum): + """Motor types for the EL406.""" + + CARRIER_X = 0 # X-axis plate carrier motor + CARRIER_Y = 1 # Y-axis plate carrier motor + DISP_HEAD_Z = 2 # Dispense head Z-axis motor + WASH_HEAD_Z = 3 # Wash head Z-axis motor + SYRINGE_A = 4 # Syringe pump A motor + SYRINGE_B = 5 # Syringe pump B motor + PERI_PUMP_PRIMARY = 6 # Primary peristaltic pump motor + PERI_PUMP_SECONDARY = 7 # Secondary peristaltic pump motor + LEVEL_SENSE_Y = 8 # Level sense Y-axis motor + WASH_SYRINGE = 9 # Wash syringe motor + WASH_ASP_HEAD_Z = 10 # Wash aspirate head Z-axis motor + SINGLE_WELL_Y = 11 # Single well Y-axis motor + + +class EL406MotorHomeType(enum.IntEnum): + """Motor home types for the EL406.""" + + INIT_ALL_MOTORS = 1 # Initialize all motors + INIT_PERI_PUMP = 2 # Initialize peristaltic pump + HOME_MOTOR = 3 # Home a specific motor + HOME_XYZ_MOTORS = 4 # Home all XYZ motors + VERIFY_MOTOR = 5 # Verify a specific motor position + VERIFY_XYZ_MOTORS = 6 # Verify all XYZ motor positions diff --git a/pylabrobot/plate_washing/biotek/el406/error_codes.py b/pylabrobot/plate_washing/biotek/el406/error_codes.py new file mode 100644 index 00000000000..d5732b535b6 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/error_codes.py @@ -0,0 +1,248 @@ +""" +BioTek EL406 Error Codes + +This module contains error codes for the BioTek EL406 plate washer. + +The error codes provide human-readable descriptions for errors that may +occur during communication with the EL406 plate washer. +""" + + +ERROR_CODES: dict[int, str] = { + 0x0175: "Error communicating with instrument software. didn't find find park opto sensor transition.", # 373 + 0x0C01: "Requested config/autocal data absent.", # 3073 + 0x0C02: "Calculated checksum didn't match checksum saved.", # 3074 + 0x0C03: "Config parameter out of range.", # 3075 + 0x1001: "Bootcode checksum error at powerup.", # 4097 + 0x1002: "Bootcode error unknown.", # 4098 + 0x1003: "Bootcode page program error.", # 4099 + 0x1004: "Bootcode block size error.", # 4100 + 0x1005: "Bootcode invalid processor signature.", # 4101 + 0x1006: "Bootcode memory exceeded.", # 4102 + 0x1007: "Bootcode invalid slave port.", # 4103 + 0x1008: "Bootcode invalid slave response.", # 4104 + 0x1009: "Bootcode invalid processor detected.", # 4105 + 0x100A: "Bootcode checksum error at powerup.", # 4106 + 0x100B: "Bootcode checksum error at powerup.", # 4107 + 0x100C: "Bootcode checksum error at powerup.", # 4108 + 0x100D: "Bootcode checksum error at powerup.", # 4109 + 0x100E: "Bootcode checksum error at powerup.", # 4110 + 0x100F: "Bootcode checksum error at powerup.", # 4111 + 0x1010: "Bootcode download checksum error.", # 4112 + 0x1250: "UI Processor internal RAM failure.", # 4688 + 0x1251: "MC Processor internal RAM failure.", # 4689 + 0x1300: "Invalid syringe", # 4864 + 0x1301: "Syringe is not connected", # 4865 + 0x1302: "Unable to initialize syringe", # 4866 + 0x1303: "Unable to initialize syringe sensor clear", # 4867 + 0x1304: "Syringe dispense volume out of calibration range", # 4868 + 0x1305: "Invalid syringe operation", # 4869 + 0x1306: "Syringe A FMEA check error", # 4870 + 0x1307: "Syringe B FMEA check error", # 4871 + 0x1355: "The Peri-pump module is not configured", # 4949 + 0x1356: "Invalid Peri-pump dispense position", # 4950 + 0x1357: "The second Peri-pump module is required", # 4951 + 0x1358: "This instrument does not support 0.5 µL Peri-pump dispense volume", # 4952 + 0x1400: "No vacuum pressure detected after turning on the vacuum pump", # 5120 + 0x1401: "The waste bottles must be emptied before continuing", # 5121 + 0x1402: "The valve to be cycle is invalid", # 5122 + 0x1403: "The magnet adapter height is out of range", # 5123 + 0x1404: "Use of the selected plate type is restricted", # 5124 + 0x1405: "Z Axis height error", # 5125 + 0x1406: "Invalid Plate type", # 5126 + 0x1407: "Invalid Step type", # 5127 + 0x1408: "Invalid plate geometry", # 5128 + 0x1409: "Invalid carrier type", # 5129 + 0x140A: "Invalid carrier specified", # 5130 + 0x140B: "Invalid carrier specified", # 5131 + 0x140C: "Invalid carrier specified", # 5132 + 0x140D: "Invalid carrier specified", # 5133 + 0x140E: "Invalid carrier specified", # 5134 + 0x140F: "Invalid carrier specified", # 5135 + 0x1410: "Incompatible hardware configuration", # 5136 + 0x1411: "Invalid carrier specified", # 5137 + 0x1412: "Plate clearance error", # 5138 + 0x1413: "AutoPrime in progress. Please wait until AutoPrime completes.", # 5139 + 0x1414: "AutoPrime is cleaning up. Please wait until AutoPrime cleanup completes.", # 5140 + 0x1415: "An AutoPrime value is out-of-range.", # 5141 + 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", # 5142 + 0x1417: "The autocal sensor was not detected in the back of the instrument.", # 5143 + 0x1430: "Strip washer syringe FMEA check error.", # 5168 + 0x1431: "Strip washer aspirate head not installed.", # 5169 + 0x1432: "Strip washer syringe box not connected.", # 5170 + 0x1433: "Bad step type pointer passed in when finding plate heights.", # 5171 + 0x1500: "There was no buffer fluid present at the start of a manifold-based protocol or at the start of an individual step.", # 5376 + 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", # 5377 + 0x1502: "The buffer valve selection is invalid", # 5378 + 0x1503: "The requested volume to be dispensed through the manifold is smaller than the minimum volume that will be dispensed by the time the DC dispense pump turns on and the dispense valve is opened.", # 5379 + 0x1504: "There was no buffer fluid detected flowing through the manifold tubing during a manifold dispense/prime operation.", # 5380 + 0x1505: "There was no buffer fluid present at the end of a manifold-based protocol or at the end of an individual step.", # 5381 + 0x1506: "The requested carrier Y-axis position is out of range.", # 5382 + 0x1514: "The Ultrasonic Advantage hardware is not configured.", # 5396 + 0x1515: "The low-flow cell-wash hardware is not configured", # 5397 + 0x1516: "Vacuum pressure issue for vacuum filtration", # 5398 + 0x1517: "The software could not read the vacuum filter hardware consistently.", # 5399 + 0x1600: "Ran out of on-board storage space", # 5632 + 0x1601: "Ran out of on-board storage space for P-Dispense steps", # 5633 + 0x1602: "Ran out of on-board storage space for P-Prime steps", # 5634 + 0x1603: "Ran out of on-board storage space for P-Purge steps", # 5635 + 0x1604: "Ran out of on-board storage space for S-Dispense steps", # 5636 + 0x1605: "Ran out of on-board storage space for S-Prime steps", # 5637 + 0x1606: "Ran out of on-board storage space for W-Wash steps", # 5638 + 0x1607: "Ran out of on-board storage space for W-Aspirate steps", # 5639 + 0x1608: "Ran out of on-board storage space for W-Dispense steps", # 5640 + 0x1609: "Ran out of on-board storage space for W-Prime steps", # 5641 + 0x160A: "Ran out of on-board storage space for W-AutoClean steps", # 5642 + 0x160B: "Ran out of on-board storage space for Shake/Soak steps", # 5643 + 0x160C: "Ran out of on-board storage space for 1536 Wash steps", # 5644 + 0x160D: "Invalid Step Type encountered", # 5645 + 0x160E: "Ran out of on-board storage space for P-Purge steps", # 5646 + 0x160F: "Ran out of on-board storage space for P-Purge steps", # 5647 + 0x1610: "Protocol transfer failed.", # 5648 + 0x1700: "Level sensor not installed.", # 5888 + 0x1701: "Level sensor framing error.", # 5889 + 0x1702: "Level sensor timing error.", # 5890 + 0x1703: "Level sensor unknown command.", # 5891 + 0x1704: "Level sensor parameter error.", # 5892 + 0x1705: "Level sensor address error.", # 5893 + 0x1706: "Level sensor error detected but not classified.", # 5894 + 0x1707: "Level sensor response cmd char != request cmd char.", # 5895 + 0x1708: "Level sensor command response not long enough.", # 5896 + 0x1709: "Level sensor command response address not equal to '0'.", # 5897 + 0x170A: "Level sensor command response checksum error.", # 5898 + 0x170B: "Level sensor timeout while looking for SOF char.", # 5899 + 0x170C: "Level sensor RX error - framing error.", # 5900 + 0x170D: "Level sensor RX error in Mode parameter.", # 5901 + 0x170E: "Level sensor RX error in Format parameter.", # 5902 + 0x170F: "Level sensor RX error in Sensitivity parameter.", # 5903 + 0x1710: "Level sensor RX error in Average parameter.", # 5904 + 0x1711: "Level sensor RX error in Temp Comp parameter.", # 5905 + 0x1712: "Level sensor RX error in SDC parameter.", # 5906 + 0x1713: "Level sensor RX error in SDE parameter.", # 5907 + 0x1714: "Level sensor RX error in setting configuration.", # 5908 + 0x1715: "Level sensor error in converting a read to a level.", # 5909 + 0x1716: "7 reads did not come up with at least 3 good ones.", # 5910 + 0x1717: "Level sensor echo range error.", # 5911 + 0x1718: "Level sensor echo width error.", # 5912 + 0x1719: "7 reads did not come up with at least 3 good ones.", # 5913 + 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", # 5914 + 0x171B: "7 reads did not come up with at least 3 good ones.", # 5915 + 0x171C: "In FindAxisCenter() initial read not > threshold.", # 5916 + 0x171D: "7 reads did not come up with at least 3 good ones.", # 5917 + 0x171E: "Level sensor - no well edge found - reached step limit.", # 5918 + 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", # 5919 + 0x1720: "Level sensor corner cal memory checksum error.", # 5920 + 0x1721: "Level sensor A1 cal memory checksum error.", # 5921 + 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", # 5922 + 0x1723: "A plate read was started but not finished successfully.", # 5923 + 0x1724: "7 reads did not come up with at least 3 good ones.", # 5924 + 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", # 5925 + 0x1726: "Input to McReqLvlSnsZPosn() out of range.", # 5926 + 0x1727: "The correction factor is out of range.", # 5927 + 0x1728: "7 reads did not come up with at least 3 good ones.", # 5928 + 0x1729: "FindLsyParkPosn() could not find the park position.", # 5929 + 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", # 5930 + 0x172B: "Row or column was 0 - must start at 1.", # 5931 + 0x172C: "Well test error - previous config not loaded.", # 5932 + 0x172D: "Well test error - wrong well.", # 5933 + 0x172E: "7 reads did not come up with at least 3 good ones.", # 5934 + 0x172F: "7 reads did not come up with at least 3 good ones.", # 5935 + 0x1730: "7 reads did not come up with at least 3 good ones.", # 5936 + 0x1731: "7 reads did not come up with at least 3 good ones.", # 5937 + 0x1732: "Level sensor - config memory checksum error.", # 5938 + 0x1733: "Well positions have not been calculated.", # 5939 + 0x1734: "Level sense correction factor not been calculated.", # 5940 + 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", # 5941 + 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", # 5942 + 0x1737: "Plate test did not find a plate.", # 5943 + 0x1738: "Level sensor - config memory checksum error.", # 5944 + 0x1739: "MC Not all level sensor cal and config data has been loaded.", # 5945 + 0x173A: "Level sensor transmission buffer should be empty before sending a command.", # 5946 + 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", # 5947 + 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", # 5948 + 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", # 5949 + 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", # 5950 + 0x173F: "7 reads did not produce at least 6 good ones.", # 5951 + 0x6100: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24832 + 0x6101: "The 405 TS does not support downloading basecode from the LHC.", # 24833 + 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24834 + 0x6110: "The Verify Manifold Test input parameters file was not found.", # 24848 + 0x6111: "The user data file for the Verify Manifold Test could not be read in.", # 24849 + 0x6112: "The Verify Manifold Test was stopped by user.", # 24850 + 0x6113: "The Verify Manifold Test is not supported.", # 24851 + 0x6114: "Invalid well specified.", # 24852 + 0x6115: "Invalid well specified.", # 24853 + 0x6116: "Invalid well specified.", # 24854 + 0x6117: "Invalid well specified.", # 24855 + 0x6118: "Invalid well specified.", # 24856 + 0x6119: "Invalid well specified.", # 24857 + 0x611A: "Invalid well specified.", # 24858 + 0x611B: "Invalid well specified.", # 24859 + 0x611C: "Invalid well specified.", # 24860 + 0x611D: "Invalid well specified.", # 24861 + 0x611E: "Invalid well specified.", # 24862 + 0x611F: "Invalid well specified.", # 24863 + 0x6120: "The carrier is not level.", # 24864 + 0x6121: "The test had an aspirate scan error.", # 24865 + 0x6122: "The test had an dispense scan error.", # 24866 + 0x6123: "Center of well not found where expected for Verify test plate.", # 24867 + 0x6124: "Incorrect plate installed for Verify test.", # 24868 + 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", # 24869 + 0x6126: "The well volume following a dispense indicates insufficient dispense.", # 24870 + 0x6127: "Scan data could not be returned from the instrument.", # 24871 + 0x6128: "Invalid well specified.", # 24872 + 0x6129: "This Verify Manifold Test step was not performed.", # 24873 + 0x6150: "The mean Dispense Volume is out of range.", # 24912 + 0x6151: "The Dispense CV % exceeds the maximum threshold.", # 24913 + 0x6152: "The Aspirate Rate is below the minimum threshold.", # 24914 + 0x6160: "This step requires Washer components to be installed and connected.", # 24928 + 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", # 24929 + 0x6162: "The Strip Washer does not support this Plate Type", # 24930 + 0x6165: "This Peri-pump does not support single well dispensing.", # 24933 + 0x6166: "The instrument does not support single well dispensing.", # 24934 + 0x6167: "The Syringe Manifold can only be used with 6-well plates", # 24935 + 0x6168: "The Syringe Manifold can only be used with 12-well plates", # 24936 + 0x6169: "The Syringe Manifold can only be used with 24-well plates", # 24937 + 0x6170: "The Syringe Manifold can only be used with 48-well plates", # 24944 + 0x6171: "The Cassette for single well dispensing does not support this plate type", # 24945 + 0x8100: "Error communicating with instrument software. Message not acknowledged (NAK).", # 33024 + 0x8101: "Error communicating with instrument software. Timeout while waiting for serial message data.", # 33025 + 0x8102: "Error communicating with instrument software. Instrument busy and unable to process message.", # 33026 + 0x8103: "Error communicating with instrument software. Receive buffer overflow error.", # 33027 + 0x8104: "Error communicating with instrument software. Communication checksum error.", # 33028 + 0x8105: "Error communicating with instrument software. Invalid structure type in byMsgStructure header field.", # 33029 + 0x8106: "Error communicating with instrument software. Invalid destination in byMsgDestination header field.", # 33030 + 0x8107: "Error communicating with instrument software. Message sent to instrument is not supported.", # 33031 + 0x8108: "Error communicating with instrument software. Message body size exceeds max limit.", # 33032 + 0x8109: "Error communicating with instrument software. Max number of requests currently running and cannot run the latest request.", # 33033 + 0x810A: "Error communicating with instrument software. No request running when response request issued.", # 33034 + 0x810B: "Error communicating with instrument software. Receive buffer overflow error.", # 33035 + 0x810C: "Error communicating with instrument software. Response for outstanding request not ready yet.", # 33036 + 0x810D: "Error communicating with instrument software. To communicate, the instrument must be at the Main Menu.", # 33037 + 0x810E: "Error communicating with instrument software. One or more request parameters are not valid.", # 33038 + 0x810F: "Error communicating with instrument software. Command not valid in current state.", # 33039 + 0xA100: " not available.", # 41216 + 0xA101: " not available.", # 41217 + 0xA102: " not available.", # 41218 + 0xA103: " not available.", # 41219 + 0xA104: " not available.", # 41220 + 0xA300: " power supply level error.", # 41728 + 0xA301: "+5v logic power supply level error.", # 41729 + 0xA302: "+24v system/motor power supply level error.", # 41730 + 0xA303: "Internal +42v PeriPump power supply level error.", # 41731 + 0xA304: "Internal reference voltage error.", # 41732 + 0xA305: "External +42v PeriPump power supply level error.", # 41733 +} + + +def get_error_message(code: int) -> str: + """ + Get the error message for a given error code. + + Args: + code: The error code to look up. + + Returns: + The error message, or a default message if not found. + """ + return ERROR_CODES.get(code, f"Unknown error code: 0x{code:04X} ({code})") diff --git a/pylabrobot/plate_washing/biotek/el406/errors.py b/pylabrobot/plate_washing/biotek/el406/errors.py new file mode 100644 index 00000000000..12bbcab09dc --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/errors.py @@ -0,0 +1,47 @@ +"""EL406 exception classes. + +This module contains exception classes used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + + +class EL406CommunicationError(Exception): + """Exception raised for FTDI/USB communication errors with the EL406. + + This exception is raised when low-level communication fails, such as: + - USB device disconnected + - FTDI driver errors + - Write/read failures + + Attributes: + operation: The operation that failed (e.g., "write", "read", "open"). + original_error: The underlying exception that caused this error. + """ + + def __init__( + self, + message: str, + operation: str = "", + original_error: Exception | None = None, + ) -> None: + super().__init__(message) + self.operation = operation + self.original_error = original_error + + +class EL406DeviceError(Exception): + """Exception raised when the EL406 device reports an error via the validity field. + + The device returns a non-zero validity code in the status poll response + when a step command fails (e.g., no buffer fluid, invalid syringe, hardware fault). + + Attributes: + error_code: The raw error code from the device (e.g., 0x1500). + message: Human-readable error description. + """ + + def __init__(self, error_code: int, message: str) -> None: + self.error_code = error_code + super().__init__(f"EL406 error 0x{error_code:04X}: {message}") diff --git a/pylabrobot/plate_washing/biotek/el406/helpers.py b/pylabrobot/plate_washing/biotek/el406/helpers.py new file mode 100644 index 00000000000..e8c7a526b22 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/helpers.py @@ -0,0 +1,514 @@ +"""EL406 helper functions and encoding utilities. + +This module contains encoding and validation helper functions used +across the BioTek EL406 plate washer backend modules. +""" + +from __future__ import annotations + +from typing import TypedDict + +from .constants import ( + MAX_FLOW_RATE, + MIN_FLOW_RATE, + SYRINGE_MAX_FLOW_RATE, + SYRINGE_MAX_PUMP_DELAY, + SYRINGE_MAX_SUBMERGE_DURATION, + SYRINGE_MAX_VOLUME, + SYRINGE_MIN_FLOW_RATE, + SYRINGE_MIN_VOLUME, + VALID_BUFFERS, + VALID_INTENSITIES, + VALID_PERISTALTIC_FLOW_RATES, + VALID_SYRINGES, +) +from .enums import EL406PlateType + + +def validate_buffer(buffer: str) -> None: + """Validate buffer selection. + + Args: + buffer: Buffer valve identifier. + + Raises: + ValueError: If buffer is invalid. + """ + if buffer.upper() not in VALID_BUFFERS: + raise ValueError( + f"Invalid buffer '{buffer}'. Must be one of: {', '.join(sorted(VALID_BUFFERS))}" + ) + + +def validate_flow_rate(flow_rate: int) -> None: + """Validate flow rate. + + Args: + flow_rate: Flow rate value. + + Raises: + ValueError: If flow rate is out of range. + """ + if not MIN_FLOW_RATE <= flow_rate <= MAX_FLOW_RATE: + raise ValueError( + f"Invalid flow rate {flow_rate}. Must be between {MIN_FLOW_RATE} and {MAX_FLOW_RATE}." + ) + + +def validate_volume(volume: float, allow_zero: bool = False) -> None: + """Validate volume. + + Args: + volume: Volume in microliters. + allow_zero: Whether zero is allowed. + + Raises: + ValueError: If volume is invalid. + """ + if volume < 0: + raise ValueError(f"Invalid volume {volume}. Must be non-negative.") + if not allow_zero and volume == 0: + raise ValueError("Volume must be greater than zero.") + + +def validate_syringe(syringe: str) -> None: + """Validate syringe selection. + + Args: + syringe: Syringe identifier (A, B, Both). + + Raises: + ValueError: If syringe is invalid. + """ + if syringe.upper() not in VALID_SYRINGES: + raise ValueError( + f"Invalid syringe '{syringe}'. Must be one of: {', '.join(sorted(VALID_SYRINGES))}" + ) + + +def validate_plate_type(plate_type: EL406PlateType | int) -> EL406PlateType: + """Validate and convert plate type to EL406PlateType enum. + + Args: + plate_type: Plate type as enum or integer value. + + Returns: + Validated EL406PlateType enum value. + + Raises: + ValueError: If plate_type is not valid. + TypeError: If plate_type is not an EL406PlateType or int. + """ + if isinstance(plate_type, EL406PlateType): + return plate_type + + if isinstance(plate_type, int): + try: + return EL406PlateType(plate_type) + except ValueError: + valid_values = [f"{pt.value} ({pt.name})" for pt in EL406PlateType] + raise ValueError( + f"Invalid plate type value: {plate_type}. Valid values are: {', '.join(valid_values)}" + ) from None + + raise TypeError( + f"Invalid plate type type: {type(plate_type).__name__}. Expected EL406PlateType or int." + ) + + +def validate_cycles(cycles: int) -> None: + """Validate cycle count (range 1-250).""" + if not 1 <= cycles <= 250: + raise ValueError(f"cycles must be 1-250, got {cycles}") + + +def validate_delay_ms(delay_ms: int) -> None: + """Validate delay in milliseconds (uint16 wire format, 0-65535).""" + if not 0 <= delay_ms <= 65535: + raise ValueError(f"delay_ms must be 0-65535, got {delay_ms}") + + +def validate_offset_xy(value: int, name: str = "offset") -> None: + """Validate X/Y offset (signed byte wire format, -128..127). + + This is the generic wire-format limit. Individual commands may enforce + tighter limits (e.g. peristaltic dispense: X -125..125, Y -40..40). + """ + if not -128 <= value <= 127: + raise ValueError(f"{name} must be -128..127, got {value}") + + +def validate_offset_z(value: int, name: str = "offset_z") -> None: + """Validate Z offset (uint16 wire format, 0-65535). + + This is the generic wire-format limit. Individual commands may enforce + tighter limits (e.g. peristaltic dispense: 1-1500). + """ + if not 0 <= value <= 65535: + raise ValueError(f"{name} must be 0-65535, got {value}") + + +def validate_travel_rate(rate: int) -> None: + """Validate travel rate (1-9).""" + if not 1 <= rate <= 9: + raise ValueError(f"travel_rate must be 1-9, got {rate}") + + +def validate_intensity(intensity: str) -> None: + """Validate shake intensity.""" + if intensity not in VALID_INTENSITIES: + raise ValueError(f"intensity must be one of {sorted(VALID_INTENSITIES)}, got {intensity!r}") + + +def validate_peristaltic_flow_rate(flow_rate: str) -> None: + """Validate peristaltic flow rate (Low/Medium/High).""" + if flow_rate not in VALID_PERISTALTIC_FLOW_RATES: + raise ValueError(f"flow_rate must be one of {VALID_PERISTALTIC_FLOW_RATES}, got {flow_rate!r}") + + +def validate_syringe_flow_rate(flow_rate: int) -> None: + """Validate syringe flow rate (1-5).""" + if not SYRINGE_MIN_FLOW_RATE <= flow_rate <= SYRINGE_MAX_FLOW_RATE: + raise ValueError( + f"Syringe flow rate must be {SYRINGE_MIN_FLOW_RATE}-{SYRINGE_MAX_FLOW_RATE}, " + f"got {flow_rate}" + ) + + +def validate_syringe_volume(volume: float) -> None: + """Validate syringe volume (80-9999 uL).""" + if not SYRINGE_MIN_VOLUME <= volume <= SYRINGE_MAX_VOLUME: + raise ValueError( + f"Syringe volume must be {SYRINGE_MIN_VOLUME}-{SYRINGE_MAX_VOLUME} uL, got {volume}" + ) + + +def validate_pump_delay(delay: int) -> None: + """Validate pump delay in milliseconds (0-5000).""" + if not 0 <= delay <= SYRINGE_MAX_PUMP_DELAY: + raise ValueError(f"Pump delay must be 0-{SYRINGE_MAX_PUMP_DELAY} ms, got {delay}") + + +def validate_submerge_duration(duration: int) -> None: + """Validate submerge duration in minutes (0-1439). + + Max is 23:59 = 1439 minutes due to HH:MM parsing limit. + """ + if not 0 <= duration <= SYRINGE_MAX_SUBMERGE_DURATION: + raise ValueError( + f"Submerge duration must be 0-{SYRINGE_MAX_SUBMERGE_DURATION} minutes, got {duration}" + ) + + +def validate_num_pre_dispenses(num_pre_dispenses: int) -> None: + """Validate number of pre-dispenses (0-255, uint8 wire format).""" + if not 0 <= num_pre_dispenses <= 255: + raise ValueError(f"num_pre_dispenses must be 0-255, got {num_pre_dispenses}") + + +def syringe_to_byte(syringe: str) -> int: + """Convert syringe letter to byte value. + + Args: + syringe: Syringe identifier (A, B, Both). + + Returns: + Byte value (A=0, B=1, Both=2). + """ + syringe_upper = syringe.upper() + if syringe_upper == "A": + return 0 + if syringe_upper == "B": + return 1 + if syringe_upper == "BOTH": + return 2 + raise ValueError(f"Invalid syringe: {syringe}") + + +def encode_volume_16bit(volume_ul: float) -> tuple[int, int]: + """Encode volume as 16-bit little-endian (2 bytes). + + Args: + volume_ul: Volume in microliters. + + Returns: + Tuple of (low_byte, high_byte). + + Raises: + ValueError: If volume exceeds uint16 range (65535). + """ + vol_int = int(volume_ul) + if vol_int < 0 or vol_int > 0xFFFF: + raise ValueError(f"Volume {volume_ul} uL exceeds 16-bit encoding range (0-65535)") + return (vol_int & 0xFF, (vol_int >> 8) & 0xFF) + + +def encode_signed_byte(value: int) -> int: + """Encode a signed value as unsigned byte (two's complement). + + Args: + value: Signed value (-128 to 127). + + Returns: + Unsigned byte value (0-255). + """ + if value < 0: + return (256 + value) & 0xFF + return value & 0xFF + + +def encode_column_mask(columns: list[int] | None) -> bytes: + """Encode list of column indices to 6-byte (48-bit) column mask. + + Each bit represents one column: 0 = skip, 1 = operate on column. + + Args: + columns: List of column indices (0-47) to select, or None for all columns. + If None, returns all 1s (all columns selected). + If empty list, returns all 0s (no columns selected). + + Returns: + 6 bytes representing the 48-bit column mask in little-endian order. + + Raises: + ValueError: If any column index is out of range (not 0-47). + """ + if columns is None: + return bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + + for col in columns: + if col < 0 or col > 47: + raise ValueError(f"Column index {col} out of range. Must be 0-47.") + + mask = [0] * 6 + for col in columns: + byte_index = col // 8 + bit_index = col % 8 + mask[byte_index] |= 1 << bit_index + + return bytes(mask) + + +def cassette_to_byte(cassette: str) -> int: + """Convert cassette type string to byte value. + + Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3). + + Args: + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Returns: + Byte value (0-3). + + Raises: + ValueError: If cassette is invalid. + """ + mapping = {"ANY": 0, "1UL": 1, "5UL": 2, "10UL": 3} + key = cassette.upper() + if key not in mapping: + raise ValueError(f"Invalid cassette '{cassette}'. Must be one of: Any, 1uL, 5uL, 10uL") + return mapping[key] + + +def encode_quadrant_mask_inverted( + rows: list[int] | None, + num_row_groups: int = 4, +) -> int: + """Encode row/quadrant selection as inverted bitmask. + + The protocol uses INVERTED encoding for the quadrant/row mask byte: + 0 = selected, 1 = deselected. This is the opposite of the well mask. + + The number of valid row groups depends on the plate type: + - 96-well: 1 row group (no row selection meaningful) + - 384-well: 2 row groups (rows 1-2) + - 1536-well: 4 row groups (rows 1-4) + + Bits beyond the num_row_groups are always 0 (unused row slots are + treated as "selected"). + + Args: + rows: List of row numbers (1 to num_row_groups) to select, or None for all. + If None, returns 0x00 (all selected in inverted encoding). + num_row_groups: Number of valid row groups for this plate type (1, 2, or 4). + + Returns: + Single byte with inverted bit encoding (only lower num_row_groups bits used). + + Raises: + ValueError: If any row number is out of range. + """ + if rows is None: + return 0x00 # All selected (inverted: 0 = selected) + + # Start with only the valid bits set (all deselected for those row groups) + # For 384-well (2 groups): max_mask = 0x03 (bits 0-1) + # For 1536-well (4 groups): max_mask = 0x0F (bits 0-3) + max_mask = (1 << num_row_groups) - 1 + mask = max_mask + for row in rows: + if row < 1 or row > num_row_groups: + raise ValueError(f"Row number {row} out of range. Must be 1-{num_row_groups}.") + mask &= ~(1 << (row - 1)) # Clear bit to select + + return mask & 0xFF + + +def columns_to_column_mask(columns: list[int] | None, plate_wells: int = 96) -> list[int] | None: + """Convert 1-indexed column numbers to 0-indexed column indices. + + For a 96-well plate, columns 1-12 map to indices 0-11. + For a 384-well plate, columns 1-24 map to indices 0-23. + For a 1536-well plate, columns 1-48 map to indices 0-47. + + Args: + columns: List of column numbers (1-based), or None for all columns. + plate_wells: Plate format (96, 384, 1536). Determines max columns. + + Returns: + List of 0-indexed column indices, or None if columns is None. + + Raises: + ValueError: If column numbers are out of range. + """ + if columns is None: + return None + + max_cols = {96: 12, 384: 24, 1536: 48}.get(plate_wells, 48) + indices = [] + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for {plate_wells}-well plate (1-{max_cols}).") + indices.append(col - 1) + return indices + + +def plate_type_max_columns(plate_type) -> int: + """Return the maximum number of columns for a plate type.""" + return PLATE_TYPE_DEFAULTS[plate_type]["cols"] + + +def plate_type_max_rows(plate_type) -> int: + """Return the maximum number of row groups for a plate type. + + 96-well: 1 row group (no row selection). + 384-well: 2 row groups. + 1536-well: 4 row groups. + """ + cols = PLATE_TYPE_DEFAULTS[plate_type]["cols"] + return {12: 1, 24: 2, 48: 4}[cols] + + +def plate_type_well_count(plate_type) -> int: + """Return the well count for a plate type (96, 384, or 1536).""" + cols = PLATE_TYPE_DEFAULTS[plate_type]["cols"] + return {12: 96, 24: 384, 48: 1536}[cols] + + +def plate_type_default_z(plate_type) -> int: + """Return the default dispenser Z height for a plate type.""" + return PLATE_TYPE_DEFAULTS[plate_type]["dispenser_height"] + + +TRAVEL_RATE_TO_BYTE: dict[str, int] = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "1 CW": 7, + "2 CW": 8, + "3 CW": 9, + "4 CW": 10, + "6 CW": 6, +} +VALID_TRAVEL_RATES = set(TRAVEL_RATE_TO_BYTE) + + +def travel_rate_to_byte(rate: str) -> int: + """Convert travel rate string to wire byte value. + + Args: + rate: Travel rate string code. + + Returns: + Byte value for wire encoding. + + Raises: + ValueError: If rate is not a valid travel rate code. + """ + if rate not in TRAVEL_RATE_TO_BYTE: + valid = sorted(TRAVEL_RATE_TO_BYTE.keys()) + raise ValueError( + f"Invalid travel rate '{rate}'. Must be one of: {', '.join(repr(r) for r in valid)}" + ) + return TRAVEL_RATE_TO_BYTE[rate] + + +INTENSITY_TO_BYTE: dict[str, int] = { + "Variable": 0x01, + "Slow": 0x02, + "Medium": 0x03, + "Fast": 0x04, +} + + +# Plate type defaults for the EL406 instrument. +PLATE_TYPE_DEFAULTS: dict[EL406PlateType, dict[str, int]] = { + EL406PlateType.PLATE_1536_WELL: { + "dispenser_height": 250, + "dispense_z": 94, + "aspirate_z": 42, + "rows": 32, + "cols": 48, + }, + EL406PlateType.PLATE_384_WELL: { + "dispenser_height": 333, + "dispense_z": 120, + "aspirate_z": 22, + "rows": 16, + "cols": 24, + }, + EL406PlateType.PLATE_384_PCR: { + "dispenser_height": 230, + "dispense_z": 83, + "aspirate_z": 2, + "rows": 16, + "cols": 24, + }, + EL406PlateType.PLATE_96_WELL: { + "dispenser_height": 336, + "dispense_z": 121, + "aspirate_z": 29, + "rows": 8, + "cols": 12, + }, + EL406PlateType.PLATE_1536_FLANGE: { + "dispenser_height": 196, + "dispense_z": 93, + "aspirate_z": 13, + "rows": 32, + "cols": 48, + }, +} + + +class WashDefaults(TypedDict): + dispense_volume: float + dispense_z: int + aspirate_z: int + + +def get_plate_type_wash_defaults(plate_type: EL406PlateType) -> WashDefaults: + """Return wash defaults for a plate type. + + Returns dict with keys: dispense_volume, dispense_z, aspirate_z. + Volume logic: 300 uL if well_count == 96, else 100 uL. + Z values are plate-type-specific defaults for dispense and aspirate. + """ + pt = PLATE_TYPE_DEFAULTS[plate_type] + return { + "dispense_volume": 300.0 if pt["cols"] == 12 else 100.0, + "dispense_z": pt["dispense_z"], + "aspirate_z": pt["aspirate_z"], + } diff --git a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py b/pylabrobot/plate_washing/biotek/el406/helpers_tests.py new file mode 100644 index 00000000000..c88c18dd871 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/helpers_tests.py @@ -0,0 +1,201 @@ +"""Tests for BioTek EL406 plate washer backend - Helper functions. + +This module contains tests for Helper functions. +""" + +import unittest + +# Import the backend module (mock is already installed by test_el406_mock import) +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.helpers import encode_column_mask + + +class TestHelperFunctions(unittest.TestCase): + """Test helper functions for encoding.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_encode_volume_little_endian(self): + """Volume should be encoded as little-endian 2 bytes.""" + # Test helper method if it exists, otherwise test via command building + cmd = self.backend._build_dispense_command( + volume=1000.0, + buffer="A", + flow_rate=5, + offset_x=0, + offset_y=0, + offset_z=100, + ) + # Current format: [0]=0x04, [1]=buffer, [2-3]=volume + # 1000 = 0x03E8, little-endian = [0xE8, 0x03] + self.assertEqual(cmd[2], 0xE8) + self.assertEqual(cmd[3], 0x03) + + def test_encode_signed_byte_positive(self): + """Positive offset should encode correctly.""" + cmd = self.backend._build_aspirate_command( + time_value=1000, + travel_rate_byte=3, + offset_x=50, + offset_y=30, + offset_z=29, + ) + # Current format: [5]=offset_x, [6]=offset_y (signed bytes) + self.assertEqual(cmd[5], 50) + self.assertEqual(cmd[6], 30) + + def test_encode_signed_byte_negative(self): + """Negative offset should encode as two's complement.""" + cmd = self.backend._build_aspirate_command( + time_value=1000, + travel_rate_byte=3, + offset_x=-30, + offset_y=-50, + offset_z=29, + ) + # Current format: [5]=offset_x, [6]=offset_y (signed bytes) + # -30 as unsigned byte: 256 - 30 = 226 = 0xE2 + self.assertEqual(cmd[5], 226) + # -50 as unsigned byte: 256 - 50 = 206 = 0xCE + self.assertEqual(cmd[6], 206) + + +class TestColumnMaskEncoding(unittest.TestCase): + """Test column mask encoding helper function. + + Column mask encodes 48 column selections into 6 bytes (48 bits). + - columns is a list of column indices (0-47) + - Each index sets the corresponding bit to 1 + - Bytes are in little-endian order + """ + + def test_encode_column_mask_none_returns_all_ones(self): + """encode_column_mask(None) should return all 1s (all wells selected).""" + + mask = encode_column_mask(None) + + self.assertEqual(len(mask), 6) + # All 48 bits set = 6 bytes of 0xFF + self.assertEqual(mask, bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) + + def test_encode_column_mask_empty_list_returns_all_zeros(self): + """encode_column_mask([]) should return all 0s (no wells selected).""" + + mask = encode_column_mask([]) + + self.assertEqual(len(mask), 6) + self.assertEqual(mask, bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_single_well_0(self): + """encode_column_mask([0]) should set bit 0 only.""" + + mask = encode_column_mask([0]) + + # Well 0 = bit 0 = 0b00000001 = 0x01 in byte 0 + self.assertEqual(mask[0], 0x01) + self.assertEqual(mask[1:], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_single_well_7(self): + """encode_column_mask([7]) should set bit 7 only.""" + + mask = encode_column_mask([7]) + + # Well 7 = bit 7 = 0b10000000 = 0x80 in byte 0 + self.assertEqual(mask[0], 0x80) + self.assertEqual(mask[1:], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_single_well_8(self): + """encode_column_mask([8]) should set bit 0 in byte 1.""" + + mask = encode_column_mask([8]) + + # Well 8 = bit 8 = bit 0 of byte 1 = 0x01 + self.assertEqual(mask[0], 0x00) + self.assertEqual(mask[1], 0x01) + self.assertEqual(mask[2:], bytes([0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_single_well_47(self): + """encode_column_mask([47]) should set bit 7 in byte 5.""" + + mask = encode_column_mask([47]) + + # Well 47 = bit 47 = bit 7 of byte 5 = 0x80 + self.assertEqual(mask[:5], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + self.assertEqual(mask[5], 0x80) + + def test_encode_column_mask_multiple_wells(self): + """encode_column_mask with multiple wells should set multiple bits.""" + + # Wells 0, 1, 2, 3 = bits 0-3 in byte 0 = 0b00001111 = 0x0F + mask = encode_column_mask([0, 1, 2, 3]) + + self.assertEqual(mask[0], 0x0F) + self.assertEqual(mask[1:], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_wells_in_different_bytes(self): + """encode_column_mask with wells spanning multiple bytes.""" + + # Wells 0 (byte 0, bit 0), 8 (byte 1, bit 0), 16 (byte 2, bit 0) + mask = encode_column_mask([0, 8, 16]) + + self.assertEqual(mask[0], 0x01) + self.assertEqual(mask[1], 0x01) + self.assertEqual(mask[2], 0x01) + self.assertEqual(mask[3:], bytes([0x00, 0x00, 0x00])) + + def test_encode_column_mask_all_48_wells(self): + """encode_column_mask with all 48 wells should return all 1s.""" + + all_wells = list(range(48)) + mask = encode_column_mask(all_wells) + + self.assertEqual(mask, bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) + + def test_encode_column_mask_first_row_96_plate(self): + """encode_column_mask for first row of 96-well plate (wells 0-11).""" + + # For 48-well selection, first 12 wells would be wells 0-11 + mask = encode_column_mask(list(range(12))) + + # Wells 0-7 = byte 0 = 0xFF + # Wells 8-11 = bits 0-3 of byte 1 = 0x0F + self.assertEqual(mask[0], 0xFF) + self.assertEqual(mask[1], 0x0F) + self.assertEqual(mask[2:], bytes([0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_out_of_range_raises(self): + """encode_column_mask should raise ValueError for well index >= 48.""" + + with self.assertRaises(ValueError) as ctx: + encode_column_mask([48]) + + self.assertIn("48", str(ctx.exception)) + + def test_encode_column_mask_negative_raises(self): + """encode_column_mask should raise ValueError for negative well index.""" + + with self.assertRaises(ValueError) as ctx: + encode_column_mask([-1]) + + self.assertIn("-1", str(ctx.exception)) + + def test_encode_column_mask_duplicate_wells_handled(self): + """encode_column_mask should handle duplicate column indices.""" + + # Duplicates should just set the same bit twice (no effect) + mask = encode_column_mask([0, 0, 0]) + + self.assertEqual(mask[0], 0x01) + self.assertEqual(mask[1:], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_encode_column_mask_unsorted_wells(self): + """encode_column_mask should handle unsorted column indices.""" + + # Order shouldn't matter + mask = encode_column_mask([3, 0, 2, 1]) + + self.assertEqual(mask[0], 0x0F) + self.assertEqual(mask[1:], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) diff --git a/pylabrobot/plate_washing/biotek/el406/mock_tests.py b/pylabrobot/plate_washing/biotek/el406/mock_tests.py new file mode 100644 index 00000000000..14e333a0e48 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/mock_tests.py @@ -0,0 +1,184 @@ +"""Mock FTDI IO for EL406 testing. + +This module provides the MockFTDI class for testing the BioTek EL406 +plate washer backend without actual hardware. + +Usage: + from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + mock_io = MockFTDI() + backend = BioTekEL406Backend(timeout=0.5) + backend.io = mock_io + await backend.setup() +""" + + +class MockFTDI: + """Mock FTDI IO wrapper for testing without hardware.""" + + ACK = b"\x06" + + def __init__(self): + self.written_data: list = [] + self.read_buffer: bytes = self._default_response_buffer() + + @staticmethod + def _default_response_buffer() -> bytes: + """Create default buffer with proper response frames.""" + header = bytes([0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + single_response = b"\x06" + header + return single_response * 20 + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes) -> int: + self.written_data.append(data) + return len(data) + + async def read(self, num_bytes: int = 1) -> bytes: + result = self.read_buffer[:num_bytes] + self.read_buffer = self.read_buffer[num_bytes:] + return result + + async def usb_purge_rx_buffer(self): + pass + + async def usb_purge_tx_buffer(self): + pass + + async def set_baudrate(self, baudrate: int): + pass + + async def set_line_property(self, bits: int, stopbits: int, parity: int): + pass + + async def set_flowctrl(self, flowctrl: int): + pass + + async def set_rts(self, level: bool): + pass + + async def set_dtr(self, level: bool): + pass + + def set_read_buffer(self, data: bytes): + """Set the read buffer with automatic framing detection. + + Automatically converts legacy test data formats to proper framed responses: + 1. ACK-only buffers: Convert to ACK+header frames + 2. Data ending with ACK (e.g., bytes([value, 0x06])): Wrap as query response + 3. Already framed data (starts with 0x06, 0x01, 0x02): Pass through as-is + + This allows existing tests written for the old protocol to work with + the new framed protocol without manual updates. + """ + if not data: + self.read_buffer = data + return + + # Check if already a properly framed response (ACK + header starting with 0x01, 0x02) + if len(data) >= 12 and data[0] == 0x06 and data[1] == 0x01 and data[2] == 0x02: + self.read_buffer = data + return + + # Case 1: All ACKs - convert to ACK+header frames + if all(b == 0x06 for b in data): + header = bytes([0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + single_response = b"\x06" + header + count = len(data) + self.read_buffer = single_response * count + return + + # Case 2: Data ending with ACK (legacy format) - wrap as query response + if data[-1] == 0x06: + actual_data = data[:-1] + prefixed_data = bytes([0x01, 0x00]) + actual_data + data_len = len(prefixed_data) + header = bytes( + [ + 0x01, + 0x02, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + data_len & 0xFF, + (data_len >> 8) & 0xFF, + 0x00, + 0x00, + ] + ) + self.read_buffer = b"\x06" + header + prefixed_data + return + + # Default: pass through as-is + self.read_buffer = data + + @staticmethod + def build_completion_frame(data: bytes = b"") -> bytes: + """Build a mock completion frame for action commands. + + Action commands expect: + 1. ACK (0x06) + 2. 11-byte header (bytes 7-8 = data length, little-endian) + 3. Data bytes + + Args: + data: Optional data bytes to include in frame. + + Returns: + Complete response bytes (ACK + header + data). + """ + data_len = len(data) + header = bytes( + [ + 0x01, + 0x02, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + data_len & 0xFF, + (data_len >> 8) & 0xFF, + 0x00, + 0x00, + ] + ) + return b"\x06" + header + data + + def set_action_response(self, data: bytes = b"", count: int = 1): + """Set up mock responses for action commands. + + Args: + data: Data bytes to include in each completion frame. + count: Number of action responses to queue. + """ + response = self.build_completion_frame(data) + self.read_buffer = response * count + + def set_query_response(self, data: bytes, count: int = 1): + """Set up mock responses for query commands. + + This wraps the data in a proper framed response format: + ACK + 11-byte header + 2-byte prefix + data + + The 2-byte prefix matches the real device response format: + - Byte 0: Status (0x01) + - Byte 1: Reserved (0x00) + + The implementation extracts data starting at byte 2, so we need + to include this prefix. + + Args: + data: Data bytes to include in each response. + count: Number of responses to queue. + """ + prefixed_data = bytes([0x01, 0x00]) + data + response = self.build_completion_frame(prefixed_data) + self.read_buffer = response * count diff --git a/pylabrobot/plate_washing/biotek/el406/protocol.py b/pylabrobot/plate_washing/biotek/el406/protocol.py new file mode 100644 index 00000000000..136d3b4567b --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/protocol.py @@ -0,0 +1,58 @@ +"""EL406 protocol framing utilities. + +This module contains the protocol framing functions for building +properly formatted messages for the BioTek EL406 plate washer. +""" + +from __future__ import annotations + +from .constants import ( + MSG_CONSTANT, + MSG_HEADER_SIZE, + MSG_START_MARKER, + MSG_VERSION_MARKER, +) + + +def build_framed_message(command: int, data: bytes = b"") -> bytes: + """Build a properly framed EL406 message. + + Protocol structure: + [0]: 0x01 (start marker) + [1]: 0x02 (version marker) + [2-3]: command (little-endian short) + [4]: 0x01 (constant) + [5-6]: reserved (ushort, typically 0) + [7-8]: data length (ushort, little-endian) + [9-10]: checksum (ushort, little-endian) + ... followed by data bytes + + Checksum is two's complement of sum of header bytes 0-8 + all data bytes. + + Args: + command: 16-bit command code + data: Optional data bytes + + Returns: + Complete framed message with header and checksum + """ + header = bytearray(MSG_HEADER_SIZE) + header[0] = MSG_START_MARKER + header[1] = MSG_VERSION_MARKER + header[2] = command & 0xFF # Command low byte + header[3] = (command >> 8) & 0xFF # Command high byte + header[4] = MSG_CONSTANT + header[5] = 0x00 # Reserved low + header[6] = 0x00 # Reserved high + header[7] = len(data) & 0xFF # Data length low + header[8] = (len(data) >> 8) & 0xFF # Data length high + + # Calculate checksum + # Sum of header bytes 0-8 + all data bytes, then two's complement + checksum_sum = sum(header[:9]) + sum(data) + checksum = (0xFFFF - checksum_sum + 1) & 0xFFFF + + header[9] = checksum & 0xFF # Checksum low + header[10] = (checksum >> 8) & 0xFF # Checksum high + + return bytes(header) + data diff --git a/pylabrobot/plate_washing/biotek/el406/queries.py b/pylabrobot/plate_washing/biotek/el406/queries.py new file mode 100644 index 00000000000..b3ca0d3835b --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/queries.py @@ -0,0 +1,188 @@ +"""EL406 query methods. + +This module contains the mixin class for query operations on the +BioTek EL406 plate washer. +""" + +from __future__ import annotations + +import enum +import logging +from typing import TypeVar + +from .constants import ( + GET_PERISTALTIC_INSTALLED_COMMAND, + GET_SENSOR_ENABLED_COMMAND, + GET_SERIAL_NUMBER_COMMAND, + GET_SYRINGE_BOX_INFO_COMMAND, + GET_SYRINGE_MANIFOLD_COMMAND, + GET_WASHER_MANIFOLD_COMMAND, + LONG_READ_TIMEOUT, + RUN_SELF_CHECK_COMMAND, +) +from .enums import ( + EL406Sensor, + EL406SyringeManifold, + EL406WasherManifold, +) + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + +_E = TypeVar("_E", bound=enum.Enum) + + +class EL406QueriesMixin: + """Mixin providing query methods for the EL406. + + This mixin provides: + - Manifold queries (washer, syringe) + - Serial number query + - Sensor status query + - Syringe box info query + - Peristaltic pump installation query + - Instrument settings query + - Self-check query + + Requires: + self._send_framed_query: Async method for sending framed queries + """ + + async def _send_framed_query( + self, + command: int, + data: bytes = b"", + timeout: float | None = None, + ) -> bytes: + raise NotImplementedError + + @staticmethod + def _extract_payload_byte(response_data: bytes) -> int: + """Extract the first payload byte, handling optional 2-byte header prefix.""" + return response_data[2] if len(response_data) > 2 else response_data[0] + + async def _query_enum(self, command: int, enum_cls: type[_E], label: str) -> _E: + """Send a framed query and parse the response byte as an *enum_cls* member.""" + logger.info("Querying %s", label) + response_data = await self._send_framed_query(command) + logger.debug("%s response data: %s", label.capitalize(), response_data.hex()) + value_byte = self._extract_payload_byte(response_data) + + try: + result = enum_cls(value_byte) + except ValueError: + logger.warning("Unknown %s: %d (0x%02X)", label, value_byte, value_byte) + raise ValueError( + f"Unknown {label}: {value_byte} (0x{value_byte:02X}). " + f"Valid types: {[m.name for m in enum_cls]}" + ) from None + + logger.info("%s: %s (0x%02X)", label.capitalize(), result.name, result.value) + return result + + async def get_washer_manifold(self) -> EL406WasherManifold: + """Query the installed washer manifold type.""" + return await self._query_enum( + GET_WASHER_MANIFOLD_COMMAND, EL406WasherManifold, "washer manifold type" + ) + + async def get_syringe_manifold(self) -> EL406SyringeManifold: + """Query the installed syringe manifold type.""" + return await self._query_enum( + GET_SYRINGE_MANIFOLD_COMMAND, EL406SyringeManifold, "syringe manifold type" + ) + + async def get_serial_number(self) -> str: + """Query the product serial number.""" + logger.info("Querying product serial number") + response_data = await self._send_framed_query(GET_SERIAL_NUMBER_COMMAND) + serial_number = response_data[2:].decode("ascii", errors="ignore").strip().rstrip("\x00") + logger.info("Product serial number: %s", serial_number) + return serial_number + + async def get_sensor_enabled(self, sensor: EL406Sensor) -> bool: + """Query whether a specific sensor is enabled.""" + logger.info("Querying sensor enabled status: %s", sensor.name) + response_data = await self._send_framed_query(GET_SENSOR_ENABLED_COMMAND, bytes([sensor.value])) + logger.debug("Sensor enabled response data: %s", response_data.hex()) + enabled = bool(self._extract_payload_byte(response_data)) + logger.info("Sensor %s enabled: %s", sensor.name, enabled) + return enabled + + async def get_syringe_box_info(self) -> dict: + """Get syringe box information.""" + logger.info("Querying syringe box info") + response_data = await self._send_framed_query(GET_SYRINGE_BOX_INFO_COMMAND) + logger.debug("Syringe box info response data: %s", response_data.hex()) + + box_type = self._extract_payload_byte(response_data) + box_size = ( + response_data[3] + if len(response_data) > 3 + else (response_data[1] if len(response_data) > 1 else 0) + ) + installed = box_type != 0 + + info = { + "box_type": box_type, + "box_size": box_size, + "installed": installed, + } + + logger.info("Syringe box info: %s", info) + return info + + async def get_peristaltic_installed(self, selector: int) -> bool: + """Check if a peristaltic pump is installed.""" + if selector < 0 or selector > 1: + raise ValueError(f"Invalid selector {selector}. Must be 0 (primary) or 1 (secondary).") + + logger.info("Querying peristaltic pump installed: selector=%d", selector) + response_data = await self._send_framed_query( + GET_PERISTALTIC_INSTALLED_COMMAND, bytes([selector]) + ) + logger.debug("Peristaltic installed response data: %s", response_data.hex()) + + installed = bool(self._extract_payload_byte(response_data)) + + logger.info("Peristaltic pump %d installed: %s", selector, installed) + return installed + + async def get_instrument_settings(self) -> dict: + """Get current instrument hardware configuration.""" + logger.info("Querying instrument settings from hardware") + + washer_manifold = await self.get_washer_manifold() + syringe_manifold = await self.get_syringe_manifold() + syringe_box = await self.get_syringe_box_info() + peristaltic_1 = await self.get_peristaltic_installed(0) + peristaltic_2 = await self.get_peristaltic_installed(1) + + settings = { + "washer_manifold": washer_manifold, + "syringe_manifold": syringe_manifold, + "syringe_box": syringe_box, + "peristaltic_pump_1": peristaltic_1, + "peristaltic_pump_2": peristaltic_2, + } + + logger.info("Instrument settings: %s", settings) + return settings + + async def run_self_check(self) -> dict: + """Run instrument self-check diagnostics.""" + logger.info("Running instrument self-check") + response_data = await self._send_framed_query(RUN_SELF_CHECK_COMMAND, timeout=LONG_READ_TIMEOUT) + logger.debug("Self-check response data: %s", response_data.hex()) + error_code = self._extract_payload_byte(response_data) + success = error_code == 0 + + result = { + "success": success, + "error_code": error_code, + "message": "Self-check passed" + if success + else f"Self-check failed (error code: {error_code})", + } + + logger.info("Self-check result: %s", result["message"]) + return result diff --git a/pylabrobot/plate_washing/biotek/el406/queries_tests.py b/pylabrobot/plate_washing/biotek/el406/queries_tests.py new file mode 100644 index 00000000000..55db761f05e --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/queries_tests.py @@ -0,0 +1,679 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Query methods. + +This module contains tests for Query methods. +""" + +import unittest + +# Import the backend module (mock is already installed by test_el406_mock import) +# Import the backend module +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, + EL406Sensor, + EL406SyringeManifold, + EL406WasherManifold, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendGetWasherManifold(unittest.IsolatedAsyncioTestCase): + """Test EL406 get washer manifold query. + + The GetWasherManifoldInstalled operation queries the installed washer manifold type. + Command byte: 216 (0xD8) + + Response format: [manifold_type_byte, ACK_byte] + - The device sends the manifold type first, then ACK + + Manifold types (EnumWasherManifold): + 0: 96-Tube Dual + 1: 192-Tube + 2: 128-Tube + 3: 96-Tube Single + 4: 96 Deep Pin + 255: Not Installed + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_washer_manifold_returns_enum(self): + """get_washer_manifold should return an EL406WasherManifold enum value.""" + # Simulate device response: manifold type byte followed by ACK + # 0 = 96-Tube Dual manifold + self.backend.io.set_read_buffer(bytes([0, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertIsInstance(result, EL406WasherManifold) + self.assertEqual(result, EL406WasherManifold.TUBE_96_DUAL) + + async def test_get_washer_manifold_192_tube(self): + """get_washer_manifold should correctly identify 192-Tube manifold.""" + # 1 = 192-Tube manifold + self.backend.io.set_read_buffer(bytes([1, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertEqual(result, EL406WasherManifold.TUBE_192) + + async def test_get_washer_manifold_128_tube(self): + """get_washer_manifold should correctly identify 128-Tube manifold.""" + # 2 = 128-Tube manifold + self.backend.io.set_read_buffer(bytes([2, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertEqual(result, EL406WasherManifold.TUBE_128) + + async def test_get_washer_manifold_96_tube_single(self): + """get_washer_manifold should correctly identify 96-Tube Single manifold.""" + # 3 = 96-Tube Single manifold + self.backend.io.set_read_buffer(bytes([3, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertEqual(result, EL406WasherManifold.TUBE_96_SINGLE) + + async def test_get_washer_manifold_deep_pin_96(self): + """get_washer_manifold should correctly identify 96 Deep Pin manifold.""" + # 4 = 96 Deep Pin manifold + self.backend.io.set_read_buffer(bytes([4, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertEqual(result, EL406WasherManifold.DEEP_PIN_96) + + async def test_get_washer_manifold_not_installed(self): + """get_washer_manifold should correctly identify when not installed.""" + # 255 = Not Installed + self.backend.io.set_read_buffer(bytes([255, 0x06])) + + result = await self.backend.get_washer_manifold() + + self.assertEqual(result, EL406WasherManifold.NOT_INSTALLED) + + async def test_get_washer_manifold_sends_correct_command(self): + """get_washer_manifold should send command byte 216 (0xD8) in framed message.""" + self.backend.io.set_read_buffer(bytes([0, 0x06])) + + await self.backend.get_washer_manifold() + + last_command = self.backend.io.written_data[-1] + # Command byte is at position 2 in framed message + self.assertEqual(last_command[2], 0xD8) + + async def test_get_washer_manifold_raises_when_device_not_initialized(self): + """get_washer_manifold should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.get_washer_manifold() + + async def test_get_washer_manifold_raises_on_timeout(self): + """get_washer_manifold should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No response + + with self.assertRaises(TimeoutError): + await self.backend.get_washer_manifold() + + async def test_get_washer_manifold_invalid_value(self): + """get_washer_manifold should raise ValueError for unknown manifold type.""" + # 100 is not a valid manifold type + self.backend.io.set_read_buffer(bytes([100, 0x06])) + + with self.assertRaises(ValueError) as ctx: + await self.backend.get_washer_manifold() + + self.assertIn("100", str(ctx.exception)) + self.assertIn("Unknown", str(ctx.exception)) + + +class TestEL406BackendGetSyringeManifold(unittest.IsolatedAsyncioTestCase): + """Test EL406 get syringe manifold query. + + The GetSyringeManifoldInstalled operation queries the installed syringe manifold type. + Command byte: 187 (0xBB) + Response byte contains manifold type. + + Response format: [manifold_type_byte, ACK_byte] + - The device sends the manifold type first, then ACK + + Syringe Manifold types (EL406SyringeManifold enum): + 0: Not Installed + 1: 16-Tube + 2: 32-Tube Large Bore + 3: 32-Tube Small Bore + 4: 16-Tube 7 + 5: 8-Tube + 6: 6 Well Plate + 7: 12 Well Plate + 8: 24 Well Plate + 9: 48 Well Plate + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_syringe_manifold_returns_enum(self): + """get_syringe_manifold should return an EL406SyringeManifold enum value.""" + # Simulate device response: manifold type byte followed by ACK + # 1 = 16-Tube manifold + self.backend.io.set_read_buffer(bytes([1, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertIsInstance(result, EL406SyringeManifold) + self.assertEqual(result, EL406SyringeManifold.TUBE_16) + + async def test_get_syringe_manifold_not_installed(self): + """get_syringe_manifold should correctly identify when not installed.""" + # 0 = Not Installed + self.backend.io.set_read_buffer(bytes([0, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.NOT_INSTALLED) + + async def test_get_syringe_manifold_tube_32_large_bore(self): + """get_syringe_manifold should correctly identify 32-Tube Large Bore manifold.""" + # 2 = 32-Tube Large Bore + self.backend.io.set_read_buffer(bytes([2, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.TUBE_32_LARGE_BORE) + + async def test_get_syringe_manifold_tube_32_small_bore(self): + """get_syringe_manifold should correctly identify 32-Tube Small Bore manifold.""" + # 3 = 32-Tube Small Bore + self.backend.io.set_read_buffer(bytes([3, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.TUBE_32_SMALL_BORE) + + async def test_get_syringe_manifold_tube_16_7(self): + """get_syringe_manifold should correctly identify 16-Tube 7 manifold.""" + # 4 = 16-Tube 7 + self.backend.io.set_read_buffer(bytes([4, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.TUBE_16_7) + + async def test_get_syringe_manifold_tube_8(self): + """get_syringe_manifold should correctly identify 8-Tube manifold.""" + # 5 = 8-Tube + self.backend.io.set_read_buffer(bytes([5, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.TUBE_8) + + async def test_get_syringe_manifold_plate_6_well(self): + """get_syringe_manifold should correctly identify 6 Well Plate manifold. + + This test is critical because manifold type 6 equals ACK_BYTE (0x06). + The framed protocol handles this by including data length in the header. + """ + # 6 = 6 Well Plate (same value as ACK_BYTE 0x06) + # Use set_query_response to properly frame the data byte + self.backend.io.set_query_response(bytes([6])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.PLATE_6_WELL) + + async def test_get_syringe_manifold_plate_12_well(self): + """get_syringe_manifold should correctly identify 12 Well Plate manifold.""" + # 7 = 12 Well Plate + self.backend.io.set_read_buffer(bytes([7, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.PLATE_12_WELL) + + async def test_get_syringe_manifold_plate_24_well(self): + """get_syringe_manifold should correctly identify 24 Well Plate manifold.""" + # 8 = 24 Well Plate + self.backend.io.set_read_buffer(bytes([8, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.PLATE_24_WELL) + + async def test_get_syringe_manifold_plate_48_well(self): + """get_syringe_manifold should correctly identify 48 Well Plate manifold.""" + # 9 = 48 Well Plate + self.backend.io.set_read_buffer(bytes([9, 0x06])) + + result = await self.backend.get_syringe_manifold() + + self.assertEqual(result, EL406SyringeManifold.PLATE_48_WELL) + + async def test_get_syringe_manifold_sends_correct_command(self): + """get_syringe_manifold should send command byte 187 (0xBB) in framed message.""" + self.backend.io.set_read_buffer(bytes([0, 0x06])) + + await self.backend.get_syringe_manifold() + + last_command = self.backend.io.written_data[-1] + # Command byte is at position 2 in framed message + self.assertEqual(last_command[2], 0xBB) + + async def test_get_syringe_manifold_raises_when_device_not_initialized(self): + """get_syringe_manifold should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.get_syringe_manifold() + + async def test_get_syringe_manifold_raises_on_timeout(self): + """get_syringe_manifold should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No response + + with self.assertRaises(TimeoutError): + await self.backend.get_syringe_manifold() + + async def test_get_syringe_manifold_invalid_value(self): + """get_syringe_manifold should raise ValueError for unknown manifold type.""" + # 100 is not a valid manifold type + self.backend.io.set_read_buffer(bytes([100, 0x06])) + + with self.assertRaises(ValueError) as ctx: + await self.backend.get_syringe_manifold() + + self.assertIn("100", str(ctx.exception)) + self.assertIn("Unknown", str(ctx.exception)) + + +class TestEL406BackendGetSerialNumber(unittest.IsolatedAsyncioTestCase): + """Test EL406 get serial number query. + + The GetInstSerialNumber operation queries the device serial number. + Command: 256 (0x0100) - 16-bit command sent as [0x00, 0x01] little-endian + Response: ASCII string followed by ACK (0x06) + + Response format: [ASCII bytes...][ACK_byte] + - The device sends ASCII bytes of the serial number, then ACK + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_serial_number_various_formats(self): + """get_serial_number should handle various serial number formats.""" + # Test with different serial number formats + test_cases = [ + (b"SN123456", "SN123456"), + (b"EL406-A1B2C3", "EL406-A1B2C3"), + (b"12345", "12345"), + (b"ABC", "ABC"), + ] + + for response_data, expected_serial in test_cases: + self.backend.io.set_query_response(response_data) + result = await self.backend.get_serial_number() + self.assertEqual(result, expected_serial) + + async def test_get_serial_number_sends_correct_command(self): + """get_serial_number should send 16-bit command 256 (0x0100) in framed message.""" + self.backend.io.set_query_response(b"SN123") + + await self.backend.get_serial_number() + + last_command = self.backend.io.written_data[-1] + # Framed message has command at bytes [2-3] (little-endian) + self.assertEqual(last_command[2], 0x00) # Low byte of 256 + self.assertEqual(last_command[3], 0x01) # High byte of 256 + + async def test_get_serial_number_raises_when_device_not_initialized(self): + """get_serial_number should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.get_serial_number() + + async def test_get_serial_number_raises_on_timeout(self): + """get_serial_number should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No response + + with self.assertRaises(TimeoutError): + await self.backend.get_serial_number() + + async def test_get_serial_number_empty_response(self): + """get_serial_number should handle empty serial (just ACK).""" + # Device returns only ACK (empty serial) + self.backend.io.set_read_buffer(b"\x06") + + result = await self.backend.get_serial_number() + + self.assertEqual(result, "") + + +class TestEL406BackendGetSensorEnabled(unittest.IsolatedAsyncioTestCase): + """Test EL406 get sensor enabled query. + + The GetSensorEnabled operation queries whether a specific sensor is enabled. + Command byte: 210 (0xD2) + Parameter: sensor type byte (0-5) + Response: [enabled_byte][ACK_byte] + - enabled_byte: 0 = disabled, 1 = enabled + + Command format: + [0] Command byte: 210 (0xD2) + [1] Sensor type byte: 0-5 (EnumSensor value) + + Response format: + [0] Enabled byte: 0 = disabled, 1 = enabled + [1] ACK (0x06) + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_sensor_enabled_returns_true_when_enabled(self): + """get_sensor_enabled should return True when sensor is enabled.""" + + # Enabled = 1 + self.backend.io.set_query_response(bytes([1])) + + result = await self.backend.get_sensor_enabled(EL406Sensor.WASTE) + + self.assertTrue(result) + + async def test_get_sensor_enabled_returns_false_when_disabled(self): + """get_sensor_enabled should return False when sensor is disabled.""" + + # Disabled = 0 + self.backend.io.set_query_response(bytes([0])) + + result = await self.backend.get_sensor_enabled(EL406Sensor.FLUID) + + self.assertFalse(result) + + async def test_get_sensor_enabled_sends_correct_command(self): + """get_sensor_enabled should send command byte 210 (0xD2) in framed message.""" + + self.backend.io.set_query_response(bytes([1])) + + await self.backend.get_sensor_enabled(EL406Sensor.VACUUM) + + # Header and data are sent as separate writes + header = self.backend.io.written_data[-2] + # Command byte is at position 2 in the 11-byte header + self.assertEqual(header[2], 0xD2) + + async def test_get_sensor_enabled_sends_sensor_type(self): + """get_sensor_enabled should include sensor type in command data.""" + + self.backend.io.set_query_response(bytes([1])) + + await self.backend.get_sensor_enabled(EL406Sensor.WASTE) + + # Header and data are sent as separate writes + header = self.backend.io.written_data[-2] + data = self.backend.io.written_data[-1] + full_command = header + data + # Framed message: 11-byte header + 1-byte data (sensor type) + self.assertEqual(len(full_command), 12) + self.assertEqual(full_command[2], 0xD2) # Command byte at position 2 + self.assertEqual(full_command[11], 1) # WASTE = 1 (data starts at byte 11) + + async def test_get_sensor_enabled_sensor_types_in_command(self): + """get_sensor_enabled should send correct sensor type byte for each sensor.""" + + test_cases = [ + (EL406Sensor.VACUUM, 0), + (EL406Sensor.WASTE, 1), + (EL406Sensor.FLUID, 2), + (EL406Sensor.FLOW, 3), + (EL406Sensor.FILTER_VAC, 4), + (EL406Sensor.PLATE, 5), + ] + + for sensor, expected_byte in test_cases: + self.backend.io.set_query_response(bytes([1])) + await self.backend.get_sensor_enabled(sensor) + + # Data byte is in the separate data write (last write) + data = self.backend.io.written_data[-1] + self.assertEqual( + data[0], expected_byte, f"Sensor {sensor.name} should send byte {expected_byte}" + ) + + async def test_get_sensor_enabled_raises_when_device_not_initialized(self): + """get_sensor_enabled should raise RuntimeError if device not initialized.""" + + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.get_sensor_enabled(EL406Sensor.VACUUM) + + async def test_get_sensor_enabled_raises_on_timeout(self): + """get_sensor_enabled should raise TimeoutError when device does not respond.""" + + self.backend.io.set_read_buffer(b"") # No response + + with self.assertRaises(TimeoutError): + await self.backend.get_sensor_enabled(EL406Sensor.VACUUM) + + +class TestGetSyringeBoxInfo(unittest.IsolatedAsyncioTestCase): + """Test get_syringe_box_info functionality. + + get_syringe_box_info retrieves syringe box configuration. + Response reads two bytes: box_type then box_size. + Response format: [box_type, box_size, ACK] = 3 bytes + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_syringe_box_info_parses_response_correctly(self): + """get_syringe_box_info should correctly parse box_type and box_size.""" + # Mock response: [box_type=2, box_size=100, ACK] + self.backend.io.set_read_buffer(b"\x02\x64\x06") + result = await self.backend.get_syringe_box_info() + self.assertEqual(result["box_type"], 2) + self.assertEqual(result["box_size"], 100) + self.assertTrue(result["installed"]) + + async def test_get_syringe_box_info_not_installed(self): + """get_syringe_box_info should report not installed when box_type is 0.""" + # Mock response: [box_type=0, box_size=0, ACK] + self.backend.io.set_read_buffer(b"\x00\x00\x06") + result = await self.backend.get_syringe_box_info() + self.assertEqual(result["box_type"], 0) + self.assertFalse(result["installed"]) + + async def test_get_syringe_box_info_sends_command(self): + """get_syringe_box_info should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + self.backend.io.set_read_buffer(b"\x01\x32\x06") + await self.backend.get_syringe_box_info() + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_get_syringe_box_info_raises_when_device_not_initialized(self): + """get_syringe_box_info should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.get_syringe_box_info() + + +class TestGetPeristalticInstalled(unittest.IsolatedAsyncioTestCase): + """Test get_peristaltic_installed functionality. + + get_peristaltic_installed checks if a peristaltic pump is installed. + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_peristaltic_installed_true_when_installed(self): + """get_peristaltic_installed should return True when pump is installed.""" + self.backend.io.set_read_buffer(b"\x01\x06") # Installed + result = await self.backend.get_peristaltic_installed(selector=0) + self.assertTrue(result) + + async def test_get_peristaltic_installed_false_when_not_installed(self): + """get_peristaltic_installed should return False when pump is not installed.""" + self.backend.io.set_read_buffer(b"\x00\x06") # Not installed + result = await self.backend.get_peristaltic_installed(selector=0) + self.assertFalse(result) + + async def test_get_peristaltic_installed_validates_selector(self): + """get_peristaltic_installed should validate selector value.""" + with self.assertRaises(ValueError): + await self.backend.get_peristaltic_installed(selector=-1) + + async def test_get_peristaltic_installed_accepts_valid_selectors(self): + """get_peristaltic_installed should accept valid selector values.""" + for selector in [0, 1]: # Primary and secondary + self.backend.io.set_read_buffer(b"\x01\x06") + # Should not raise + result = await self.backend.get_peristaltic_installed(selector=selector) + self.assertIsInstance(result, bool) + + async def test_get_peristaltic_installed_sends_command(self): + """get_peristaltic_installed should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + self.backend.io.set_read_buffer(b"\x01\x06") + await self.backend.get_peristaltic_installed(selector=0) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_get_peristaltic_installed_includes_selector_in_command(self): + """get_peristaltic_installed should include selector in command.""" + self.backend.io.set_read_buffer(b"\x01\x06") + await self.backend.get_peristaltic_installed(selector=1) + last_command = self.backend.io.written_data[-1] + # Selector should be in the command + self.assertIn(1, list(last_command)) + + async def test_get_peristaltic_installed_raises_when_device_not_initialized(self): + """get_peristaltic_installed should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.get_peristaltic_installed(selector=0) + + +if __name__ == "__main__": + unittest.main() + + +class TestGetInstrumentSettings(unittest.IsolatedAsyncioTestCase): + """Test get_instrument_settings functionality. + + get_instrument_settings queries hardware configuration by calling + multiple sequential query commands. + """ + + def _build_multi_query_buffer(self): + """Build mock buffer with 5 sequential framed query responses. + + get_instrument_settings calls in order: + 1. get_washer_manifold -> manifold type byte + 2. get_syringe_manifold -> manifold type byte + 3. get_syringe_box_info -> box_type, box_size + 4. get_peristaltic_installed(0) -> installed byte + 5. get_peristaltic_installed(1) -> installed byte + + Each response is: ACK + 11-byte header + 2-byte prefix + data + """ + buf = b"" + # 1. washer manifold: TUBE_96_DUAL (0) + buf += MockFTDI.build_completion_frame(bytes([0x01, 0x00, 0x00])) + # 2. syringe manifold: TUBE_32_LARGE_BORE (2) + buf += MockFTDI.build_completion_frame(bytes([0x01, 0x00, 0x02])) + # 3. syringe box info: box_type=1, box_size=100 + buf += MockFTDI.build_completion_frame(bytes([0x01, 0x00, 0x01, 0x64])) + # 4. peristaltic 0: installed (1) + buf += MockFTDI.build_completion_frame(bytes([0x01, 0x00, 0x01])) + # 5. peristaltic 1: not installed (0) + buf += MockFTDI.build_completion_frame(bytes([0x01, 0x00, 0x00])) + return buf + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.read_buffer = self._build_multi_query_buffer() + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_get_instrument_settings_returns_dict(self): + """get_instrument_settings should return a dictionary.""" + result = await self.backend.get_instrument_settings() + self.assertIsInstance(result, dict) + + async def test_get_instrument_settings_queries_hardware(self): + """get_instrument_settings should query multiple hardware settings.""" + result = await self.backend.get_instrument_settings() + self.assertIn("washer_manifold", result) + self.assertIn("syringe_manifold", result) + self.assertIn("syringe_box", result) + self.assertIn("peristaltic_pump_1", result) + self.assertIn("peristaltic_pump_2", result) + + async def test_get_instrument_settings_returns_correct_values(self): + """get_instrument_settings should return correct hardware configuration.""" + result = await self.backend.get_instrument_settings() + self.assertEqual(result["washer_manifold"], EL406WasherManifold.TUBE_96_DUAL) + self.assertEqual(result["syringe_manifold"], EL406SyringeManifold.TUBE_32_LARGE_BORE) + self.assertEqual(result["syringe_box"]["installed"], True) + self.assertEqual(result["syringe_box"]["box_type"], 1) + self.assertEqual(result["syringe_box"]["box_size"], 100) + self.assertEqual(result["peristaltic_pump_1"], True) + self.assertEqual(result["peristaltic_pump_2"], False) + + async def test_get_instrument_settings_raises_when_device_not_initialized(self): + """get_instrument_settings should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.get_instrument_settings() diff --git a/pylabrobot/plate_washing/biotek/el406/setup_tests.py b/pylabrobot/plate_washing/biotek/el406/setup_tests.py new file mode 100644 index 00000000000..dfc7afe7caa --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/setup_tests.py @@ -0,0 +1,132 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Setup, serialization, and configuration. + +This module contains tests for setup, serialization, error classes, +and plate type configuration. +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, + EL406CommunicationError, + EL406PlateType, +) +from pylabrobot.plate_washing.biotek.el406.helpers import validate_plate_type +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendSetup(unittest.IsolatedAsyncioTestCase): + """Test EL406 backend setup and teardown.""" + + async def test_setup_creates_io(self): + """Setup should create and configure FTDI IO wrapper.""" + backend = BioTekEL406Backend(timeout=0.5) + backend.io = MockFTDI() + await backend.setup() + + self.assertIsNotNone(backend.io) + + async def test_stop_closes_device(self): + """Stop should close the FTDI device.""" + backend = BioTekEL406Backend(timeout=0.5) + backend.io = MockFTDI() + await backend.setup() + + self.assertIsNotNone(backend.io) + await backend.stop() + + self.assertIsNone(backend.io) + + +class TestEL406CommunicationError(unittest.TestCase): + """Test EL406CommunicationError exception class.""" + + def test_exception_attributes(self): + """EL406CommunicationError should preserve message, operation, and original error.""" + original = OSError("USB disconnect") + error = EL406CommunicationError("FTDI error", operation="read", original_error=original) + self.assertEqual(str(error), "FTDI error") + self.assertEqual(error.operation, "read") + self.assertIs(error.original_error, original) + + # Defaults + simple = EL406CommunicationError("Test") + self.assertEqual(simple.operation, "") + self.assertIsNone(simple.original_error) + + +class TestEL406BackendSerialization(unittest.TestCase): + """Test EL406 backend serialization.""" + + def test_serialize(self): + """Backend should serialize correctly.""" + backend = BioTekEL406Backend(timeout=30.0) + serialized = backend.serialize() + + self.assertEqual(serialized["type"], "BioTekEL406Backend") + self.assertEqual(serialized["timeout"], 30.0) + + def test_init_without_ftdi_available(self): + """Backend should be instantiable without FTDI library.""" + backend = BioTekEL406Backend() + self.assertIsNone(backend.io) + + +class TestPlateTypeConfiguration(unittest.TestCase): + """Test plate type configuration.""" + + def test_set_and_get_plate_type_round_trip(self): + """set_plate_type and get_plate_type should work together.""" + backend = BioTekEL406Backend() + for plate_type in EL406PlateType: + backend.set_plate_type(plate_type) + result = backend.get_plate_type() + self.assertEqual(result, plate_type) + + def test_serialize_includes_plate_type(self): + """Backend serialization should include plate_type.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + serialized = backend.serialize() + self.assertEqual(serialized["plate_type"], EL406PlateType.PLATE_384_WELL.value) + + def test_set_plate_type_does_not_send_command(self): + """set_plate_type should NOT send any command to the device.""" + backend = BioTekEL406Backend(timeout=0.5) + backend.io = MockFTDI() + initial_count = len(backend.io.written_data) + backend.set_plate_type(EL406PlateType.PLATE_384_WELL) + self.assertEqual(len(backend.io.written_data), initial_count) + + +class TestPlateTypeValidation(unittest.TestCase): + """Test plate type validation.""" + + def test_validate_plate_type_accepts_enum(self): + """validate_plate_type should accept EL406PlateType enum values.""" + for plate_type in EL406PlateType: + validate_plate_type(plate_type) + + def test_validate_plate_type_accepts_int_values(self): + """validate_plate_type should accept valid integer values.""" + for value in [0, 1, 2, 4, 14]: + result = validate_plate_type(value) + self.assertIsInstance(result, EL406PlateType) + + def test_validate_plate_type_raises_for_invalid_int(self): + """validate_plate_type should raise ValueError for invalid integers.""" + with self.assertRaises(ValueError): + validate_plate_type(-1) + with self.assertRaises(ValueError): + validate_plate_type(3) + with self.assertRaises(ValueError): + validate_plate_type(100) + + def test_validate_plate_type_raises_for_invalid_type(self): + """validate_plate_type should raise for invalid types.""" + with self.assertRaises((ValueError, TypeError)): + validate_plate_type("96-well") + with self.assertRaises((ValueError, TypeError)): + validate_plate_type(None) + with self.assertRaises((ValueError, TypeError)): + validate_plate_type([1, 2, 3]) diff --git a/pylabrobot/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/plate_washing/biotek/el406/steps/__init__.py new file mode 100644 index 00000000000..07224cd005b --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/__init__.py @@ -0,0 +1,33 @@ +"""EL406 protocol step methods. + +This package contains the mixin class for protocol step operations on the +BioTek EL406 plate washer (prime, dispense, aspirate, wash, shake, etc.). + +The methods are split into per-subsystem modules for maintainability, but +the composed ``EL406StepsMixin`` class is the only public API. +""" + +from ._manifold import EL406ManifoldStepsMixin +from ._peristaltic import EL406PeristalticStepsMixin +from ._shake import EL406ShakeStepsMixin +from ._syringe import EL406SyringeStepsMixin + + +class EL406StepsMixin( + EL406PeristalticStepsMixin, + EL406SyringeStepsMixin, + EL406ManifoldStepsMixin, + EL406ShakeStepsMixin, +): + """Mixin providing all protocol step methods for the EL406. + + This class composes all per-subsystem step mixins: + - Peristaltic: peristaltic_prime, peristaltic_dispense, peristaltic_purge + - Syringe: syringe_dispense, syringe_prime + - Manifold: manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, manifold_auto_clean + - Shake: shake + + Requires: + self._send_step_command: Async method for sending framed commands + self.timeout: Default timeout in seconds + """ diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_base.py b/pylabrobot/plate_washing/biotek/el406/steps/_base.py new file mode 100644 index 00000000000..117661f29b3 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/_base.py @@ -0,0 +1,27 @@ +"""Base mixin providing type stubs for EL406 step sub-mixins. + +Sub-mixins inherit from this class so they can reference +``self._send_step_command`` and ``self.timeout`` without circular imports. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..enums import EL406PlateType + + +class EL406StepsBaseMixin: + """Type stubs consumed by the per-subsystem step mixins.""" + + plate_type: EL406PlateType + timeout: float + + if TYPE_CHECKING: + + async def _send_step_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + ... diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py new file mode 100644 index 00000000000..3f0d7bce694 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py @@ -0,0 +1,1609 @@ +"""EL406 manifold step methods. + +Provides manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, +and manifold_auto_clean operations plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from ..constants import ( + MANIFOLD_ASPIRATE_COMMAND, + MANIFOLD_AUTO_CLEAN_COMMAND, + MANIFOLD_DISPENSE_COMMAND, + MANIFOLD_PRIME_COMMAND, + MANIFOLD_WASH_COMMAND, +) +from ..helpers import ( + INTENSITY_TO_BYTE, + VALID_TRAVEL_RATES, + encode_signed_byte, + encode_volume_16bit, + get_plate_type_wash_defaults, + travel_rate_to_byte, + validate_buffer, + validate_cycles, + validate_delay_ms, + validate_flow_rate, + validate_intensity, + validate_offset_xy, + validate_offset_z, + validate_travel_rate, + validate_volume, +) +from ..protocol import build_framed_message +from ._base import EL406StepsBaseMixin + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class EL406ManifoldStepsMixin(EL406StepsBaseMixin): + """Mixin for manifold step operations.""" + + @staticmethod + def _validate_manifold_xy(x: int, y: int, label: str) -> None: + """Validate manifold X/Y offsets (X: -60..60, Y: -40..40).""" + if not -60 <= x <= 60: + raise ValueError(f"{label} X offset must be -60..60, got {x}") + if not -40 <= y <= 40: + raise ValueError(f"{label} Y offset must be -40..40, got {y}") + + @staticmethod + def _validate_aspirate_mode_params( + vacuum_filtration: bool, + travel_rate: str, + delay_ms: int, + vacuum_time_sec: int, + ) -> tuple[int, int]: + """Validate aspirate mode-specific params and return (time_value, rate_byte).""" + if not vacuum_filtration: + if travel_rate not in VALID_TRAVEL_RATES: + raise ValueError( + f"Invalid travel rate '{travel_rate}'. Must be one of: " + f"{', '.join(repr(r) for r in sorted(VALID_TRAVEL_RATES))}" + ) + if not 0 <= delay_ms <= 5000: + raise ValueError(f"Aspirate delay must be 0-5000 ms, got {delay_ms}") + return (delay_ms, travel_rate_to_byte(travel_rate)) + + if not 5 <= vacuum_time_sec <= 999: + raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") + return (vacuum_time_sec, travel_rate_to_byte("3")) + + @classmethod + def _validate_aspirate_offsets( + cls, + offset_x: int, + offset_y: int, + offset_z: int, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: int, + ) -> None: + """Validate aspirate XYZ offset ranges (primary and secondary).""" + cls._validate_manifold_xy(offset_x, offset_y, "Aspirate") + if not 1 <= offset_z <= 210: + raise ValueError(f"Aspirate Z offset must be 1-210, got {offset_z}") + if secondary_aspirate: + cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if not 1 <= secondary_z <= 210: + raise ValueError(f"Secondary Z offset must be 1-210, got {secondary_z}") + + def _validate_aspirate_params( + self, + vacuum_filtration: bool, + travel_rate: str, + delay_ms: int, + vacuum_time_sec: int, + offset_x: int, + offset_y: int, + offset_z: int | None, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: int | None, + ) -> tuple[int, int, int, int]: + """Validate aspirate parameters and resolve plate-type defaults. + + Returns: + (offset_z, secondary_z, time_value, rate_byte) + """ + pt_defaults = get_plate_type_wash_defaults(self.plate_type) + if offset_z is None: + offset_z = pt_defaults["aspirate_z"] + if secondary_z is None: + secondary_z = pt_defaults["aspirate_z"] + + time_value, rate_byte = self._validate_aspirate_mode_params( + vacuum_filtration, + travel_rate, + delay_ms, + vacuum_time_sec, + ) + self._validate_aspirate_offsets( + offset_x, + offset_y, + offset_z, + secondary_aspirate, + secondary_x, + secondary_y, + secondary_z, + ) + return (offset_z, secondary_z, time_value, rate_byte) + + @staticmethod + def _validate_dispense_extras( + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + ) -> None: + """Validate pre-dispense and vacuum-delay parameters for manifold dispense.""" + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 µL, " + f"got {pre_dispense_volume}" + ) + if not 3 <= pre_dispense_flow_rate <= 11: + raise ValueError( + f"Manifold pre-dispense flow rate must be 3-11, got {pre_dispense_flow_rate}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Manifold vacuum delay volume must be 0-3000 µL, got {vacuum_delay_volume}") + + def _validate_dispense_params( + self, + volume: float, + buffer: str, + flow_rate: int, + offset_x: int, + offset_y: int, + offset_z: int | None, + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + ) -> int: + """Validate dispense parameters and resolve plate-type defaults. + + Returns: + Resolved offset_z. + """ + if offset_z is None: + pt_defaults = get_plate_type_wash_defaults(self.plate_type) + offset_z = pt_defaults["dispense_z"] + + if not 25 <= volume <= 3000: + raise ValueError(f"Manifold dispense volume must be 25-3000 µL, got {volume}") + validate_buffer(buffer) + if not 1 <= flow_rate <= 11: + raise ValueError(f"Manifold dispense flow rate must be 1-11, got {flow_rate}") + if flow_rate <= 2 and vacuum_delay_volume <= 0: + raise ValueError( + f"Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0, " + f"got flow_rate={flow_rate} with vacuum_delay_volume={vacuum_delay_volume}" + ) + self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") + if not 1 <= offset_z <= 210: + raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") + self._validate_dispense_extras(pre_dispense_volume, pre_dispense_flow_rate, vacuum_delay_volume) + + return offset_z + + def _resolve_wash_defaults( + self, + dispense_volume: float | None, + dispense_z: int | None, + aspirate_z: int | None, + secondary_z: int | None, + final_secondary_z: int | None, + ) -> tuple[float, int, int, int, int]: + """Resolve plate-type-aware defaults for wash parameters.""" + pt_defaults = get_plate_type_wash_defaults(self.plate_type) + if dispense_volume is None: + dispense_volume = pt_defaults["dispense_volume"] + if dispense_z is None: + dispense_z = pt_defaults["dispense_z"] + if aspirate_z is None: + aspirate_z = pt_defaults["aspirate_z"] + if secondary_z is None: + secondary_z = pt_defaults["aspirate_z"] + if final_secondary_z is None: + final_secondary_z = pt_defaults["aspirate_z"] + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + + @staticmethod + def _validate_wash_core_params( + cycles: int, + buffer: str, + dispense_volume: float, + dispense_flow_rate: int, + dispense_x: int, + dispense_y: int, + dispense_z: int, + aspirate_travel_rate: int, + aspirate_z: int, + pre_dispense_flow_rate: int, + aspirate_delay_ms: int, + aspirate_x: int, + aspirate_y: int, + wash_format: Literal["Plate", "Sector"], + sector_mask: int, + ) -> None: + """Validate core wash dispense/aspirate parameters.""" + validate_cycles(cycles) + validate_volume(dispense_volume) + validate_buffer(buffer) + validate_flow_rate(dispense_flow_rate) + validate_offset_xy(dispense_x, "Wash dispense X") + validate_offset_xy(dispense_y, "Wash dispense Y") + validate_offset_z(dispense_z, "Wash dispense Z") + validate_travel_rate(aspirate_travel_rate) + if wash_format not in ("Plate", "Sector"): + raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{wash_format}'") + if not 0 <= sector_mask <= 0xFFFF: + raise ValueError(f"sector_mask must be 0x0000-0xFFFF, got 0x{sector_mask:04X}") + validate_offset_z(aspirate_z, "Wash aspirate Z") + validate_flow_rate(pre_dispense_flow_rate) + validate_delay_ms(aspirate_delay_ms) + validate_offset_xy(aspirate_x, "Wash aspirate X") + validate_offset_xy(aspirate_y, "Wash aspirate Y") + + @staticmethod + def _validate_wash_final_and_extras( + final_aspirate_z: int | None, + final_aspirate_x: int, + final_aspirate_y: int, + final_aspirate_delay_ms: int, + pre_dispense_volume: float, + vacuum_delay_volume: float, + soak_duration: int, + shake_duration: int, + shake_intensity: str, + ) -> None: + """Validate final-aspirate, pre-dispense, soak/shake parameters.""" + if final_aspirate_z is not None: + validate_offset_z(final_aspirate_z, "Final aspirate Z") + validate_offset_xy(final_aspirate_x, "Final aspirate X") + validate_offset_xy(final_aspirate_y, "Final aspirate Y") + validate_delay_ms(final_aspirate_delay_ms) + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, " + f"got {pre_dispense_volume}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") + if not 0 <= soak_duration <= 3599: + raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {soak_duration}") + if not 0 <= shake_duration <= 3599: + raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {shake_duration}") + validate_intensity(shake_intensity) + + @classmethod + def _validate_wash_secondary_aspirates( + cls, + secondary_aspirate: bool, + secondary_z: int, + secondary_x: int, + secondary_y: int, + final_secondary_aspirate: bool, + final_secondary_z: int, + final_secondary_x: int, + final_secondary_y: int, + ) -> None: + """Validate secondary and final-secondary aspirate offsets.""" + if secondary_aspirate: + validate_offset_z(secondary_z, "Wash secondary aspirate Z") + cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if final_secondary_aspirate: + validate_offset_z(final_secondary_z, "Final secondary aspirate Z") + cls._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") + + @staticmethod + def _validate_wash_optional_features( + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + ) -> None: + """Validate bottom wash and mid-cycle pre-dispense.""" + if bottom_wash: + if not 25 <= bottom_wash_volume <= 3000: + raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {bottom_wash_volume}") + validate_flow_rate(bottom_wash_flow_rate) + if pre_dispense_between_cycles_volume != 0: + if not 25 <= pre_dispense_between_cycles_volume <= 3000: + raise ValueError( + f"Pre-dispense between cycles volume must be 0 (disabled) or " + f"25-3000 uL, got {pre_dispense_between_cycles_volume}" + ) + validate_flow_rate(pre_dispense_between_cycles_flow_rate) + + def _validate_wash_params( + self, + cycles: int, + buffer: str, + dispense_volume: float | None, + dispense_flow_rate: int, + dispense_x: int, + dispense_y: int, + dispense_z: int | None, + aspirate_travel_rate: int, + aspirate_z: int | None, + pre_dispense_flow_rate: int, + aspirate_delay_ms: int, + aspirate_x: int, + aspirate_y: int, + final_aspirate_z: int | None, + final_aspirate_x: int, + final_aspirate_y: int, + final_aspirate_delay_ms: int, + pre_dispense_volume: float, + vacuum_delay_volume: float, + soak_duration: int, + shake_duration: int, + shake_intensity: str, + secondary_aspirate: bool, + secondary_z: int | None, + secondary_x: int, + secondary_y: int, + final_secondary_aspirate: bool, + final_secondary_z: int | None, + final_secondary_x: int, + final_secondary_y: int, + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + wash_format: Literal["Plate", "Sector"], + sector_mask: int, + ) -> tuple[float, int, int, int, int]: + """Validate wash parameters and resolve plate-type defaults. + + Returns: + (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + """ + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._resolve_wash_defaults( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) + self._validate_wash_core_params( + cycles, + buffer, + dispense_volume, + dispense_flow_rate, + dispense_x, + dispense_y, + dispense_z, + aspirate_travel_rate, + aspirate_z, + pre_dispense_flow_rate, + aspirate_delay_ms, + aspirate_x, + aspirate_y, + wash_format, + sector_mask, + ) + self._validate_wash_final_and_extras( + final_aspirate_z, + final_aspirate_x, + final_aspirate_y, + final_aspirate_delay_ms, + pre_dispense_volume, + vacuum_delay_volume, + soak_duration, + shake_duration, + shake_intensity, + ) + self._validate_wash_secondary_aspirates( + secondary_aspirate, + secondary_z, + secondary_x, + secondary_y, + final_secondary_aspirate, + final_secondary_z, + final_secondary_x, + final_secondary_y, + ) + self._validate_wash_optional_features( + bottom_wash, + bottom_wash_volume, + bottom_wash_flow_rate, + pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate, + ) + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + + async def manifold_aspirate( + self, + vacuum_filtration: bool = False, + travel_rate: str = "3", + delay_ms: int = 0, + vacuum_time_sec: int = 30, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + secondary_aspirate: bool = False, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: int | None = None, + ) -> None: + """Aspirate liquid from all wells via the wash manifold. + + Two modes based on vacuum_filtration: + - Normal (vacuum_filtration=False): Uses travel_rate and delay_ms. + - Vacuum filtration (vacuum_filtration=True): Uses vacuum_time_sec. + Travel rate is ignored (greyed out in GUI). + + Args: + vacuum_filtration: Enable vacuum filtration mode. + travel_rate: Head travel rate. Normal: "1"-"5". + Cell wash: "1 CW", "2 CW", "3 CW", "4 CW", "6 CW". + Ignored when vacuum_filtration=True. + delay_ms: Post-aspirate delay in milliseconds (0-5000). Only used when + vacuum_filtration=False. + vacuum_time_sec: Vacuum filtration time in seconds (5-999). Only used when + vacuum_filtration=True. + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). Default None (plate-type-aware: + 29 for 96-well, 22 for 384-well, etc.). + secondary_aspirate: Enable secondary aspirate (perform a second aspirate + at a different position). Not available for 1536-well plates. + secondary_x: Secondary aspirate X offset (-60 to +60). + secondary_y: Secondary aspirate Y offset (-40 to +40). + secondary_z: Secondary aspirate Z offset (1-210). Default None + (plate-type-aware, same as offset_z default). + + Raises: + ValueError: If parameters are invalid. + """ + offset_z, secondary_z, time_value, rate_byte = self._validate_aspirate_params( + vacuum_filtration=vacuum_filtration, + travel_rate=travel_rate, + delay_ms=delay_ms, + vacuum_time_sec=vacuum_time_sec, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + secondary_aspirate=secondary_aspirate, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + ) + + logger.info( + "Aspirating: vacuum=%s, travel_rate=%s, delay=%d ms", + vacuum_filtration, + travel_rate, + delay_ms, + ) + + data = self._build_aspirate_command( + vacuum_filtration=vacuum_filtration, + time_value=time_value, + travel_rate_byte=rate_byte, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + secondary_mode=1 if secondary_aspirate else 0, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + ) + framed_command = build_framed_message(MANIFOLD_ASPIRATE_COMMAND, data) + await self._send_step_command(framed_command) + + async def manifold_dispense( + self, + volume: float, + buffer: str = "A", + flow_rate: int = 7, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> None: + """Dispense liquid to all wells via the wash manifold. + + Args: + volume: Volume to dispense in µL/well. Range: 25-3000 µL (manifold-dependent: + 96-tube manifolds require ≥50, 192/128-tube manifolds allow ≥25). + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Dispense flow rate (1-11, default 7). + Rates 1-2 are for cell wash mode only (96-tube dual-action manifold) + and require vacuum_delay_volume > 0. + Standard range is 3-11. + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). Default None (plate-type-aware: + 121 for 96-well, 120 for 384-well, etc.). + pre_dispense_volume: Pre-dispense volume in µL/tube (0 to disable, 25-3000 when enabled). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11, default 9). + vacuum_delay_volume: Delay start of vacuum until volume dispensed in µL/well + (0 to disable, 0-3000 when enabled). Required for cell wash flow rates 1-2. + + Raises: + ValueError: If parameters are invalid. + """ + offset_z = self._validate_dispense_params( + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + + logger.info( + "Dispensing %.1f uL from buffer %s, flow rate %d", + volume, + buffer, + flow_rate, + ) + + data = self._build_dispense_command( + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + framed_command = build_framed_message(MANIFOLD_DISPENSE_COMMAND, data) + await self._send_step_command(framed_command) + + async def manifold_wash( + self, + cycles: int = 3, + buffer: str = "A", + dispense_volume: float | None = None, + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: int | None = None, + aspirate_travel_rate: int = 3, + aspirate_z: int | None = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay_ms: int = 0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: int | None = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay_ms: int = 0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: str = "Medium", + secondary_aspirate: bool = False, + secondary_z: int | None = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: int | None = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sectors: list[int] | None = None, + move_home_first: bool = False, + ) -> None: + """Perform manifold wash cycles. + + Sends a 102-byte MANIFOLD_WASH (0xA4) command that performs repeated + dispense-aspirate cycles. The wire format contains two dispense sections, + two aspirate sections, and a final shake/soak section. + + The wash command supports 4 independent coordinate sets: + - Primary aspirate (aspirate_x/y/z): between-cycle aspirate position + - Primary secondary (secondary_x/y/z): second aspirate position per cycle + - Final aspirate (final_aspirate_x/y/z): aspirate after last cycle + - Final secondary (final_secondary_x/y/z): second position for final aspirate + + Args: + cycles: Number of wash cycles (1-250). Default 3. + Encoded at header byte [6]. + buffer: Buffer valve selection (A, B, C, D). Default A. + dispense_volume: Volume to dispense per cycle in uL. Default None + (plate-type-aware: 300 for 96-well, 100 for others). + dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. + dispense_x: Dispense X offset in steps (-60 to +60). Default 0. + dispense_y: Dispense Y offset in steps (-40 to +40). Default 0. + dispense_z: Z offset for dispense in 0.1mm units (1-210). Default None + (plate-type-aware: 121 for 96-well, 120 for 384-well, etc.). + aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. + aspirate_z: Z offset for aspirate in 0.1mm units (1-210). Default None + (plate-type-aware: 29 for 96-well, 22 for 384-well, etc.). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Default 9. + Controls how fast the pre-dispense is delivered. + aspirate_delay_ms: Post-aspirate delay in milliseconds (0-5000). Default 0. + aspirate_x: Aspirate X offset in steps (-60 to +60). Default 0. + aspirate_y: Aspirate Y offset in steps (-40 to +40). Default 0. + final_aspirate: Enable final aspirate after last cycle. Default True. + Encoded in header config flags byte [2]. + final_aspirate_z: Z offset for final aspirate (1-210). Default None + (inherits from aspirate_z). Independent from primary aspirate Z. + final_aspirate_x: X offset for final aspirate (-60 to +60). Default 0. + final_aspirate_y: Y offset for final aspirate (-40 to +40). Default 0. + final_aspirate_delay_ms: Post-aspirate delay for final aspirate in + milliseconds (0-5000). Default 0. Encoded at Disp1[20-21] (wire + [27-28]) as 16-bit LE. + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, + 25-3000 when enabled). Default 0.0. + vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, + 0-3000 when enabled). Cell wash operations only. Default 0.0. + soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. + shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. + shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + Default "Medium". + secondary_aspirate: Enable secondary aspirate for primary (between-cycle) + aspirate. Default False. + secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). + Default None (plate-type-aware, same as aspirate_z default). + secondary_x: Secondary aspirate X offset (-60 to +60). Default 0. + secondary_y: Secondary aspirate Y offset (-40 to +40). Default 0. + final_secondary_aspirate: Enable secondary aspirate for final aspirate. + Default False. + final_secondary_z: Z offset for final secondary aspirate (1-210). + Default None (plate-type-aware, same as aspirate_z default). + final_secondary_x: X offset for final secondary aspirate (-60 to +60). + Default 0. + final_secondary_y: Y offset for final secondary aspirate (-40 to +40). + Default 0. + bottom_wash: Enable bottom wash. Default False. Encoded in header[1]. + bottom_wash_volume: Bottom wash volume in uL (25-3000). Default 0.0. + bottom_wash_flow_rate: Bottom wash flow rate (3-11). Default 5. + pre_dispense_between_cycles_volume: Pre-dispense volume between wash + cycles in uL (0 to disable, 25-3000 when enabled). Default 0.0. + pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between + cycles (3-11). Default 9. + wash_format: Wash format ("Plate" or "Sector"). Default "Plate". + Encoded at header[3]: Plate=0x00, Sector=0x01. + 384-well plates typically use "Sector" for quadrant-based washing. + sectors: List of quadrant numbers to wash (1-4). Default None (all 4). + Example: ``sectors=[1, 2]`` washes quadrants 1 and 2. + Only used when wash_format="Sector". + move_home_first: Move carrier to home position before shake/soak. + Default False. Same as in standalone shake interface. + Encoded at wire [87] (shake/soak section byte 0). + + Raises: + ValueError: If parameters are invalid. + """ + # Convert sectors list to bitmask + if sectors is not None: + sector_mask = 0 + for q in sectors: + if not 1 <= q <= 4: + raise ValueError(f"Sector/quadrant must be 1-4, got {q}") + sector_mask |= 1 << (q - 1) + else: + sector_mask = 0x0F + + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._validate_wash_params( + cycles=cycles, + buffer=buffer, + dispense_volume=dispense_volume, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=aspirate_z, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay_ms=aspirate_delay_ms, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate_z=final_aspirate_z, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay_ms=final_aspirate_delay_ms, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sector_mask=sector_mask, + ) + + logger.info( + "Manifold wash: %d cycles, %.1f uL, buffer %s, flow %d, " + "disp_xy=(%d,%d), z_disp=%d, z_asp=%d, pre_disp_flow=%d, " + "asp_delay=%d, asp_xy=(%d,%d), final_asp=%s, " + "pre_disp=%.1f, vac_delay=%.1f, soak=%d, shake=%d/%s, " + "sec_asp=%s, sec_z=%d, sec_xy=(%d,%d), " + "btm_wash=%s/%.1f/%d, midcyc=%.1f/%d", + cycles, + dispense_volume, + buffer, + dispense_flow_rate, + dispense_x, + dispense_y, + dispense_z, + aspirate_z, + pre_dispense_flow_rate, + aspirate_delay_ms, + aspirate_x, + aspirate_y, + final_aspirate, + pre_dispense_volume, + vacuum_delay_volume, + soak_duration, + shake_duration, + shake_intensity, + secondary_aspirate, + secondary_z, + secondary_x, + secondary_y, + bottom_wash, + bottom_wash_volume, + bottom_wash_flow_rate, + pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate, + ) + + data = self._build_wash_composite_command( + cycles=cycles, + buffer=buffer, + dispense_volume=dispense_volume, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=aspirate_z, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay_ms=aspirate_delay_ms, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate=final_aspirate, + final_aspirate_z=final_aspirate_z, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay_ms=final_aspirate_delay_ms, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sector_mask=sector_mask, + move_home_first=move_home_first, + ) + + framed_command = build_framed_message(MANIFOLD_WASH_COMMAND, data) + # Dynamic timeout: base per cycle + shake + soak + buffer + # Each cycle takes ~10-30s depending on volume/flow/plate type. + # Use 60s per cycle as generous safety margin to avoid false timeouts. + wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 + await self._send_step_command(framed_command, timeout=wash_timeout) + + async def manifold_prime( + self, + volume: float, + buffer: str = "A", + flow_rate: int = 9, + low_flow_volume: int = 5, + submerge_duration: int = 0, + ) -> None: + """Prime the manifold fluid lines. + + Fills the wash manifold tubing with liquid from the specified buffer. + This is typically done at the start of a protocol to ensure the lines + are filled and ready for dispensing. + + Args: + volume: Prime volume in mL (not uL!). Range: 5-999 mL. + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Flow rate (3-11, default 9). + low_flow_volume: Low flow path volume in mL (5-999, default 5). Set to 0 to disable. + submerge_duration: Submerge duration in minutes (0 to disable, 1-1439 when enabled). + Limit: 00:01-23:59. Wire encoding is total minutes. + + Raises: + ValueError: If parameters are invalid. + """ + # Parameter limits + if not 5 <= volume <= 999: + raise ValueError(f"Washer prime volume must be 5-999 mL, got {volume}") + validate_buffer(buffer) + if not 3 <= flow_rate <= 11: + raise ValueError(f"Washer prime flow rate must be 3-11, got {flow_rate}") + if low_flow_volume != 0 and not 5 <= low_flow_volume <= 999: + raise ValueError( + f"Low flow path volume must be 0 (disabled) or 5-999 mL, got {low_flow_volume}" + ) + if submerge_duration != 0 and not 1 <= submerge_duration <= 1439: + raise ValueError( + f"Submerge duration must be 0 (disabled) or 1-1439 minutes (00:01-23:59), " + f"got {submerge_duration}" + ) + + low_flow_enabled = low_flow_volume > 0 + submerge_enabled = submerge_duration > 0 + + logger.info( + "Manifold prime: %.1f mL from buffer %s, flow rate %d, low_flow=%s/%d mL, submerge=%s/%d min", + volume, + buffer, + flow_rate, + "enabled" if low_flow_enabled else "disabled", + low_flow_volume, + "enabled" if submerge_enabled else "disabled", + submerge_duration, + ) + + data = self._build_manifold_prime_command( + buffer=buffer, + volume=volume, + flow_rate=flow_rate, + low_flow_volume=low_flow_volume, + low_flow_enabled=low_flow_enabled, + submerge_enabled=submerge_enabled, + submerge_duration=submerge_duration, + ) + framed_command = build_framed_message(MANIFOLD_PRIME_COMMAND, data) + # Timeout: base time for priming + submerge duration (in minutes) + buffer + prime_timeout = self.timeout + (submerge_duration * 60) + 30 + await self._send_step_command(framed_command, timeout=prime_timeout) + + async def manifold_auto_clean( + self, + buffer: str = "A", + duration: int = 1, + ) -> None: + """Run a manifold auto-clean cycle. + + Args: + buffer: Buffer valve to use (A, B, C, or D). + duration: Cleaning duration in minutes (1-239, i.e. up to 3h59m). + + Raises: + ValueError: If parameters are invalid. + """ + validate_buffer(buffer) + # AutoClean Duration must be 00:01..03:59 + # 03:59 = 3*60 + 59 = 239 minutes + if not 1 <= duration <= 239: + raise ValueError(f"AutoClean duration must be 1-239 minutes (00:01-03:59), got {duration}") + + logger.info("Auto-clean: buffer %s, duration %d minutes", buffer, duration) + + data = self._build_auto_clean_command( + buffer=buffer, + duration=duration, + ) + framed_command = build_framed_message(MANIFOLD_AUTO_CLEAN_COMMAND, data) + # Auto-clean can take 1+ minutes, use longer timeout (duration is in minutes on wire) + auto_clean_timeout = max(120.0, duration * 60.0 + 30.0) # At least 2 min, or duration + 30s + await self._send_step_command(framed_command, timeout=auto_clean_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _encode_wash_byte_values( + self, + buffer: str, + dispense_volume: float | None, + dispense_z: int | None, + aspirate_z: int | None, + dispense_x: int, + dispense_y: int, + aspirate_x: int, + aspirate_y: int, + final_aspirate_z: int | None, + final_aspirate_x: int, + final_aspirate_y: int, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: int | None, + final_secondary_aspirate: bool, + final_secondary_x: int, + final_secondary_y: int, + final_secondary_z: int | None, + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + aspirate_delay_ms: int, + final_aspirate_delay_ms: int, + shake_duration: int, + shake_intensity: str, + soak_duration: int, + dispense_flow_rate: int, + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + ) -> dict[str, int]: + """Pre-compute all byte-level wire values for a wash command. + + Returns a dict of named byte values used by the section builders in + ``_build_wash_composite_command``. + """ + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._resolve_wash_defaults( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) + + vol_low, vol_high = encode_volume_16bit(dispense_volume) + + final_asp_z = final_aspirate_z if final_aspirate_z is not None else aspirate_z + + # Pre-dispense volume (16-bit LE, 0 = disabled) + pre_disp_int = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + # Vacuum delay volume (16-bit LE, 0 = disabled) + vac_delay_int = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + + # Primary secondary aspirate + sec_mode_byte = 0x01 if secondary_aspirate else 0x00 + sec_x_byte = encode_signed_byte(secondary_x) if secondary_aspirate else 0x00 + sec_y_byte = encode_signed_byte(secondary_y) if secondary_aspirate else 0x00 + + # Final secondary aspirate + final_sec_mode_byte = 0x01 if final_secondary_aspirate else 0x00 + final_sec_x_byte = encode_signed_byte(final_secondary_x) if final_secondary_aspirate else 0x00 + final_sec_y_byte = encode_signed_byte(final_secondary_y) if final_secondary_aspirate else 0x00 + final_sec_z = final_secondary_z if final_secondary_aspirate else final_asp_z + + # Shake intensity byte (only encode when shake is actually enabled) + intensity_byte = INTENSITY_TO_BYTE.get(shake_intensity, 0x03) if shake_duration > 0 else 0x00 + + # Bottom wash: when enabled, Dispense1 gets bottom wash params + if bottom_wash: + bw_vol = int(bottom_wash_volume) + bw_vol_low = bw_vol & 0xFF + bw_vol_high = (bw_vol >> 8) & 0xFF + bw_flow = bottom_wash_flow_rate + else: + bw_vol_low = vol_low + bw_vol_high = vol_high + bw_flow = dispense_flow_rate + + # Pre-dispense between cycles + if pre_dispense_between_cycles_volume > 0: + midcyc_vol = int(pre_dispense_between_cycles_volume) + midcyc_vol_low = midcyc_vol & 0xFF + midcyc_vol_high = (midcyc_vol >> 8) & 0xFF + midcyc_flow = pre_dispense_between_cycles_flow_rate + else: + midcyc_vol_low = pre_disp_int & 0xFF + midcyc_vol_high = (pre_disp_int >> 8) & 0xFF + midcyc_flow = pre_dispense_flow_rate + + return { + "buffer_char": ord(buffer.upper()), + "vol_low": vol_low, + "vol_high": vol_high, + "disp_z_low": dispense_z & 0xFF, + "disp_z_high": (dispense_z >> 8) & 0xFF, + "asp_z_low": aspirate_z & 0xFF, + "asp_z_high": (aspirate_z >> 8) & 0xFF, + "disp_x_byte": encode_signed_byte(dispense_x), + "disp_y_byte": encode_signed_byte(dispense_y), + "asp_x_byte": encode_signed_byte(aspirate_x), + "asp_y_byte": encode_signed_byte(aspirate_y), + "final_asp_z_low": final_asp_z & 0xFF, + "final_asp_z_high": (final_asp_z >> 8) & 0xFF, + "final_asp_x_byte": encode_signed_byte(final_aspirate_x), + "final_asp_y_byte": encode_signed_byte(final_aspirate_y), + "sec_mode_byte": sec_mode_byte, + "sec_x_byte": sec_x_byte, + "sec_y_byte": sec_y_byte, + "sec_z_low": secondary_z & 0xFF, + "sec_z_high": (secondary_z >> 8) & 0xFF, + "final_sec_mode_byte": final_sec_mode_byte, + "final_sec_x_byte": final_sec_x_byte, + "final_sec_y_byte": final_sec_y_byte, + "final_sec_z_low": final_sec_z & 0xFF, + "final_sec_z_high": (final_sec_z >> 8) & 0xFF, + "pre_disp_low": pre_disp_int & 0xFF, + "pre_disp_high": (pre_disp_int >> 8) & 0xFF, + "vac_delay_low": vac_delay_int & 0xFF, + "vac_delay_high": (vac_delay_int >> 8) & 0xFF, + "asp_delay_low": aspirate_delay_ms & 0xFF, + "asp_delay_high": (aspirate_delay_ms >> 8) & 0xFF, + "final_asp_delay_low": final_aspirate_delay_ms & 0xFF, + "final_asp_delay_high": (final_aspirate_delay_ms >> 8) & 0xFF, + "shake_dur_low": shake_duration & 0xFF, + "shake_dur_high": (shake_duration >> 8) & 0xFF, + "soak_dur_low": soak_duration & 0xFF, + "soak_dur_high": (soak_duration >> 8) & 0xFF, + "intensity_byte": intensity_byte, + "bw_vol_low": bw_vol_low, + "bw_vol_high": bw_vol_high, + "bw_flow": bw_flow, + "midcyc_vol_low": midcyc_vol_low, + "midcyc_vol_high": midcyc_vol_high, + "midcyc_flow": midcyc_flow, + } + + def _build_wash_composite_command( + self, + cycles: int = 3, + buffer: str = "A", + dispense_volume: float | None = None, + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: int | None = None, + aspirate_travel_rate: int = 3, + aspirate_z: int | None = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay_ms: int = 0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: int | None = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay_ms: int = 0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: str = "Medium", + secondary_aspirate: bool = False, + secondary_z: int | None = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: int | None = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sector_mask: int = 0x0F, + move_home_first: bool = False, + ) -> bytes: + """Build 102-byte MANIFOLD_WASH (0xA4) command payload. + + Structure: header(7) + dispense1(22) + final_aspirate(20) + primary_aspirate(19) + + dispense2(22) + shake_soak(12) = 102 bytes. + + Header [0-6]: + [0] plate_type (from self.plate_type.value) + [1] bottom_wash enable + [2] config flags -- final_aspirate + [3] wash_format -- 0=Plate, 1=Sector + [4-5] sector_mask as 16-bit LE + [6] wash cycles count + + Four coordinate sets for aspirate positions: + - Primary: aspirate_x/y/z (between-cycle aspirate, wire [49-67]) + - Primary secondary: secondary_x/y/z (wire [55-61]) + - Final: final_aspirate_x/y/z (post-cycle aspirate, wire [29-48]) + - Final secondary: final_secondary_x/y/z (wire [37-41]) + + Returns: + 102-byte command payload. + """ + v = self._encode_wash_byte_values( + buffer=buffer, + dispense_volume=dispense_volume, + dispense_z=dispense_z, + aspirate_z=aspirate_z, + dispense_x=dispense_x, + dispense_y=dispense_y, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate_z=final_aspirate_z, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + secondary_aspirate=secondary_aspirate, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + final_secondary_z=final_secondary_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + aspirate_delay_ms=aspirate_delay_ms, + final_aspirate_delay_ms=final_aspirate_delay_ms, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + soak_duration=soak_duration, + dispense_flow_rate=dispense_flow_rate, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + ) + + # Header [0-6] (7 bytes) + config_flags = 0x01 if final_aspirate else 0x00 + bw_flag = 0x01 if bottom_wash else 0x00 + wash_format_byte = {"Plate": 0x00, "Sector": 0x01}[wash_format] + header = bytes( + [ + self.plate_type.value, # [0] Plate type + bw_flag, # [1] Bottom wash enable + config_flags, # [2] Config flags + wash_format_byte, # [3] Wash format: 0=Plate, 1=Sector + sector_mask & 0xFF, # [4] Sector mask low byte + (sector_mask >> 8) & 0xFF, # [5] Sector mask high byte + cycles, # [6] Number of wash cycles + ] + ) + + # Dispense section 1 [7-28] (22 bytes) — bottom wash or mirror of main + disp1 = bytes( + [ + v["buffer_char"], # [0] Buffer ASCII + v["bw_vol_low"], + v["bw_vol_high"], # [1-2] Volume LE + v["bw_flow"], # [3] Flow rate + v["disp_x_byte"], + v["disp_y_byte"], # [4-5] Offset X, Y (signed) + v["disp_z_low"], + v["disp_z_high"], # [6-7] Dispense Z LE + v["pre_disp_low"], + v["pre_disp_high"], # [8-9] Pre-dispense volume LE + pre_dispense_flow_rate, # [10] Pre-dispense flow rate + v["vac_delay_low"], + v["vac_delay_high"], # [11-12] Vacuum delay volume LE + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # [13-19] Padding (7 bytes) + v["final_asp_delay_low"], + v["final_asp_delay_high"], # [20-21] Final aspirate delay ms LE + ] + ) + + # Final aspirate section [29-48] (20 bytes) + asp1 = bytes( + [ + aspirate_travel_rate, # [0] Travel rate (propagated) + 0x00, + 0x00, # [1-2] Delay ms LE (always 0 here) + v["final_asp_z_low"], + v["final_asp_z_high"], # [3-4] Final aspirate Z LE + v["final_sec_mode_byte"], # [5] Final secondary mode + v["final_asp_x_byte"], # [6] Final aspirate X offset + v["final_asp_y_byte"], # [7] Final aspirate Y offset + v["final_sec_z_low"], + v["final_sec_z_high"], # [8-9] Final secondary Z LE + 0x00, # [10] Reserved + v["final_sec_x_byte"], # [11] Final secondary X + v["final_sec_y_byte"], # [12] Final secondary Y + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # [13-17] Reserved (5 bytes) + 0x00, # [18] vac_filt (always 0 in wash) + v["asp_delay_low"], # [19] aspirate_delay_ms low byte + ] + ) + + # Primary aspirate section [49-67] (19 bytes) + asp2 = bytes( + [ + v["asp_delay_high"], # [0] aspirate_delay_ms high byte + aspirate_travel_rate, # [1] Travel rate + v["asp_x_byte"], # [2] Aspirate X offset + v["asp_y_byte"], # [3] Aspirate Y offset + v["asp_z_low"], + v["asp_z_high"], # [4-5] Aspirate Z LE + v["sec_mode_byte"], # [6] Secondary aspirate mode (0=off, 1=on) + v["sec_x_byte"], # [7] Secondary X offset + v["sec_y_byte"], # [8] Secondary Y offset + v["sec_z_low"], + v["sec_z_high"], # [9-10] Secondary Z LE + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # [11-18] Reserved + ] + ) + + # Dispense section 2 [68-86] (19 bytes) -- main dispense + disp2 = bytes( + [ + v["buffer_char"], # [0] Buffer ASCII + v["vol_low"], + v["vol_high"], # [1-2] Volume LE (main dispense) + dispense_flow_rate, # [3] Flow rate (main) + v["disp_x_byte"], + v["disp_y_byte"], # [4-5] Offset X, Y (signed) + v["disp_z_low"], + v["disp_z_high"], # [6-7] Dispense Z LE + v["midcyc_vol_low"], + v["midcyc_vol_high"], # [8-9] Pre-disp between cycles vol LE + v["midcyc_flow"], # [10] Pre-disp between cycles flow rate + v["vac_delay_low"], + v["vac_delay_high"], # [11-12] Vacuum delay volume LE + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # [13-18] Padding (6 bytes) + ] + ) + + # Shake/soak section [87-101] (15 bytes = 11 + 4 trailing) + move_home_byte = 0x01 if move_home_first else 0x00 + shake_soak = bytes( + [ + move_home_byte, # [0] move_home_first + v["shake_dur_low"], + v["shake_dur_high"], # [1-2] shake duration LE (seconds) + v["intensity_byte"] if shake_duration > 0 else 0x03, + # [3] shake intensity (default 3=Medium) + 0x00, # [4] shake type (always 0) + v["soak_dur_low"], + v["soak_dur_high"], # [5-6] soak duration LE (seconds) + 0x00, + 0x00, + 0x00, + 0x00, # [7-10] padding + 0x00, + 0x00, + 0x00, + 0x00, # trailing padding (4 bytes) + ] + ) + + data = header + disp1 + asp1 + asp2 + disp2 + shake_soak + + assert len(data) == 102, f"Wash command should be 102 bytes, got {len(data)}" + + logger.debug("Wash command data (%d bytes): %s", len(data), data.hex()) + return data + + def _build_aspirate_command( + self, + vacuum_filtration: bool = False, + time_value: int = 0, + travel_rate_byte: int = 3, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 30, + secondary_mode: int = 0, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: int = 30, + ) -> bytes: + """Build aspirate command bytes. + + Wire format (22 bytes): + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] vacuum_filtration: 0 or 1 + [2-3] time_value: ushort LE. delay_ms when normal, vacuum_time_sec when vacuum. + [4] travel_rate: byte from lookup table + [5] x_offset: signed byte + [6] y_offset: signed byte + [7-8] z_offset: short LE + [9] secondary_mode: byte (0=None, 1=enabled) + [10] secondary_x: signed byte + [11] secondary_y: signed byte + [12-13] secondary_z: short LE + [14-15] reserved: 0x0000 + [16-17] column mask: 2 bytes (all columns selected: 0xFF 0x0F) + [18-21] padding: 4 bytes 0x00 + + Args: + vacuum_filtration: Enable vacuum filtration. + time_value: Delay in ms (normal mode) or time in seconds (vacuum mode). + travel_rate_byte: Pre-encoded travel rate byte value. + offset_x: X offset (signed byte). + offset_y: Y offset (signed byte). + offset_z: Z offset (unsigned short). + secondary_mode: Secondary aspirate mode byte (0=None, 1=enabled). + secondary_x: Secondary X offset (signed byte). + secondary_y: Secondary Y offset (signed byte). + secondary_z: Secondary Z offset (unsigned short). + + Returns: + Command bytes (22 bytes). + """ + # Column mask: always all columns selected for manifold aspirate + column_mask_bytes = bytes([0xFF, 0x0F]) + + return ( + bytes( + [ + self.plate_type.value, # [0] plate type prefix + 1 if vacuum_filtration else 0, # [1] vacuum_filtration + time_value & 0xFF, # [2] time/delay low + (time_value >> 8) & 0xFF, # [3] time/delay high + travel_rate_byte & 0xFF, # [4] travel rate + encode_signed_byte(offset_x), # [5] x offset + encode_signed_byte(offset_y), # [6] y offset + offset_z & 0xFF, # [7] z offset low + (offset_z >> 8) & 0xFF, # [8] z offset high + secondary_mode & 0xFF, # [9] secondary mode + encode_signed_byte(secondary_x), # [10] secondary x + encode_signed_byte(secondary_y), # [11] secondary y + secondary_z & 0xFF, # [12] secondary z low + (secondary_z >> 8) & 0xFF, # [13] secondary z high + 0, + 0, # [14-15] reserved + ] + ) + + column_mask_bytes + + bytes([0, 0, 0, 0]) + ) # [16-17] column mask + [18-21] padding + + def _build_dispense_command( + self, + volume: float, + buffer: str, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 121, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> bytes: + """Build manifold dispense command bytes. + + Protocol format for manifold dispense: + Wire format: 20 bytes (19 + plate type prefix) + + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, LE, in µL (25-3000) + [4] Flow rate: 1-11 (1-2 = cell wash, requires vacuum delay) + [5] Offset X: signed byte (-60..60) + [6] Offset Y: signed byte (-40..40) + [7-8] Offset Z: 2 bytes, LE (1-210) + [9-10] Pre-dispense volume: 2 bytes, LE (0 if disabled, 25-3000 when enabled) + [11] Pre-dispense flow rate: 3-11 + [12-13] Vacuum delay volume: 2 bytes, LE (0 if disabled, 0-3000) + [14-19] Padding: 6 bytes (0x00) + + Note: Pre-dispense is enabled when pre_dispense_volume > 0. + Vacuum delay is enabled when vacuum_delay_volume > 0. + + Args: + volume: Dispense volume in µL. + buffer: Buffer valve (A, B, C, D). + flow_rate: Flow rate (1-11; 1-2 = cell wash, requires vacuum delay). + offset_x: X offset (signed, steps, -60..60). + offset_y: Y offset (signed, steps, -40..40). + offset_z: Z offset (steps, 1-210). + pre_dispense_volume: Pre-dispense volume in µL (0 to disable). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). + vacuum_delay_volume: Vacuum delay volume in µL (0 to disable). + + Returns: + Command bytes (20 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + z_low = offset_z & 0xFF + z_high = (offset_z >> 8) & 0xFF + + # Pre-dispense volume (enabled when > 0) + pre_disp_vol_int = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + pre_disp_low = pre_disp_vol_int & 0xFF + pre_disp_high = (pre_disp_vol_int >> 8) & 0xFF + + # Vacuum delay volume (enabled when > 0) + vac_delay_int = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + vac_delay_low = vac_delay_int & 0xFF + vac_delay_high = (vac_delay_int >> 8) & 0xFF + + return bytes( + [ + self.plate_type.value, # [0] Plate type prefix + ord(buffer.upper()), # [1] Buffer as ASCII char + vol_low, # [2] Volume low + vol_high, # [3] Volume high + flow_rate, # [4] Flow rate + encode_signed_byte(offset_x), # [5] X offset + encode_signed_byte(offset_y), # [6] Y offset + z_low, # [7] Z offset low + z_high, # [8] Z offset high + pre_disp_low, # [9] Pre-dispense volume low + pre_disp_high, # [10] Pre-dispense volume high + pre_dispense_flow_rate, # [11] Pre-dispense flow rate + vac_delay_low, # [12] Vacuum delay volume low + vac_delay_high, # [13] Vacuum delay volume high + 0, + 0, + 0, + 0, + 0, + 0, # [14-19] Padding (6 bytes) + ] + ) + + def _build_manifold_prime_command( + self, + buffer: str, + volume: float, + flow_rate: int = 9, + low_flow_volume: int = 5, + low_flow_enabled: bool = True, + submerge_enabled: bool = False, + submerge_duration: int = 0, + ) -> bytes: + """Build manifold prime command bytes. + + Protocol format for manifold prime (13 bytes): + + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, little-endian, in mL + [4] Flow rate: 3-11 + [5-6] Low flow volume: 2 bytes, little-endian (in mL, 0 if disabled) + [7-8] Submerge duration: 2 bytes, little-endian (in minutes, 0 if disabled) + HH:MM encoded as total minutes: hours*60+minutes + [9-12] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + volume: Prime volume in mL (not uL!). + flow_rate: Flow rate (3-11, default 9). + low_flow_volume: Low flow volume in mL (default 5). + low_flow_enabled: Enable low flow path (default True). + submerge_enabled: Enable submerge tips after prime (default False). + submerge_duration: Submerge duration in minutes (default 0). + + Returns: + Command bytes (13 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + + # Low flow volume: 16-bit LE, but only if enabled + if low_flow_enabled and low_flow_volume > 0: + lf_low = low_flow_volume & 0xFF + lf_high = (low_flow_volume >> 8) & 0xFF + else: + lf_low = 0 + lf_high = 0 + + # Submerge duration: 16-bit LE in minutes, only if enabled + if submerge_enabled: + sub_low = submerge_duration & 0xFF + sub_high = (submerge_duration >> 8) & 0xFF + else: + sub_low = 0 + sub_high = 0 + + return bytes( + [ + self.plate_type.value, # Plate type prefix + ord(buffer.upper()), # Buffer as ASCII char + vol_low, + vol_high, + flow_rate, + lf_low, # Low flow volume low byte + lf_high, # Low flow volume high byte + sub_low, # Submerge duration low byte (minutes) + sub_high, # Submerge duration high byte (minutes) + 0, + 0, + 0, + 0, # Padding (4 bytes) + ] + ) + + def _build_auto_clean_command( + self, + buffer: str, + duration: int = 1, + ) -> bytes: + """Build auto-clean command bytes. + + Protocol format for auto-clean (8 bytes): + + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Duration: 2 bytes, little-endian (in minutes) + [4-7] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + duration: Cleaning duration in minutes (1-239). + + Returns: + Command bytes (8 bytes). + """ + duration_int = int(duration) & 0xFFFF + duration_low = duration_int & 0xFF + duration_high = (duration_int >> 8) & 0xFF + + return bytes( + [ + self.plate_type.value, # Plate type prefix + ord(buffer.upper()), # Buffer as ASCII char + duration_low, + duration_high, + 0, + 0, + 0, + 0, # Padding (4 bytes) + ] + ) diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py new file mode 100644 index 00000000000..672908ef9f4 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py @@ -0,0 +1,436 @@ +"""EL406 peristaltic pump step methods. + +Provides peristaltic_prime, peristaltic_dispense, and peristaltic_purge operations +plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from ..constants import ( + PERISTALTIC_DISPENSE_COMMAND, + PERISTALTIC_PRIME_COMMAND, + PERISTALTIC_PURGE_COMMAND, +) +from ..helpers import ( + cassette_to_byte, + columns_to_column_mask, + encode_column_mask, + encode_quadrant_mask_inverted, + encode_signed_byte, + encode_volume_16bit, + plate_type_default_z, + plate_type_max_columns, + plate_type_max_rows, + plate_type_well_count, + validate_num_pre_dispenses, + validate_peristaltic_flow_rate, + validate_volume, +) +from ..protocol import build_framed_message +from ._base import EL406StepsBaseMixin + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + +PERISTALTIC_FLOW_RATE_MAP: dict[str, int] = {"Low": 0, "Medium": 1, "High": 2} + + +class EL406PeristalticStepsMixin(EL406StepsBaseMixin): + """Mixin for peristaltic pump step operations.""" + + def _validate_peristaltic_well_selection( + self, + columns: list[int] | None, + rows: list[int] | None, + ) -> list[int] | None: + """Validate column/row selection and return column mask.""" + max_cols = plate_type_max_columns(self.plate_type) + if columns is not None: + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for plate type (1-{max_cols}).") + + max_rows = plate_type_max_rows(self.plate_type) + if rows is not None: + for row in rows: + if row < 1 or row > max_rows: + raise ValueError(f"Row {row} out of range for plate type (1-{max_rows}).") + + return columns_to_column_mask(columns, plate_wells=plate_type_well_count(self.plate_type)) + + def _validate_peristaltic_dispense_params( + self, + volume: float, + flow_rate: Literal["Low", "Medium", "High"], + offset_x: int, + offset_y: int, + offset_z: int | None, + pre_dispense_volume: float, + columns: list[int] | None, + rows: list[int] | None, + ) -> tuple[int, int, list[int] | None]: + """Validate peristaltic dispense parameters and resolve defaults. + + Returns: + (offset_z, flow_rate_enum, column_mask) + """ + if not 1 <= volume <= 3000: + raise ValueError(f"Peri-pump dispense volume must be 1-3000 µL, got {volume}") + validate_peristaltic_flow_rate(flow_rate) + if not -125 <= offset_x <= 125: + raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x}") + if not -40 <= offset_y <= 40: + raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y}") + + if offset_z is None: + offset_z = plate_type_default_z(self.plate_type) + if not 1 <= offset_z <= 1500: + raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z}") + + validate_volume(pre_dispense_volume, allow_zero=True) + + column_mask = self._validate_peristaltic_well_selection(columns, rows) + + return (offset_z, PERISTALTIC_FLOW_RATE_MAP[flow_rate], column_mask) + + async def peristaltic_prime( + self, + volume: float | None = None, + duration: int | None = None, + flow_rate: Literal["Low", "Medium", "High"] = "High", + cassette: Literal["Any", "1uL", "5uL", "10uL"] = "Any", + ) -> None: + """Prime the peristaltic fluid lines. + + Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. + If neither is given, defaults to volume mode with 1000 uL. + + Note: Peristaltic prime has no buffer selection. + Use ``manifold_prime()`` for buffer-specific priming. + + Args: + volume: Volume to prime in microliters. + duration: Fixed duration in seconds (alternative to volume). + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Raises: + ValueError: If both volume and duration are specified, or if parameters are invalid. + """ + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + prime_volume = 0.0 + prime_duration = duration + else: + if volume is None: + volume = 1000.0 + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 µL (GUI limit)") + prime_volume = volume + prime_duration = 0 + + validate_peristaltic_flow_rate(flow_rate) + + logger.info( + "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", prime_volume, flow_rate, cassette + ) + + data = self._build_peristaltic_prime_command( + volume=prime_volume, + duration=prime_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], + reverse=True, + cassette=cassette, + pump=1, + ) + framed_command = build_framed_message(PERISTALTIC_PRIME_COMMAND, data) + # Timeout: duration (if specified) + buffer for volume-based priming + prime_timeout = self.timeout + prime_duration + 30 + await self._send_step_command(framed_command, timeout=prime_timeout) + + async def peristaltic_dispense( + self, + volume: float, + flow_rate: Literal["Low", "Medium", "High"] = "High", + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + pre_dispense_volume: float = 10.0, + num_pre_dispenses: int = 2, + cassette: Literal["Any", "1uL", "5uL", "10uL"] = "Any", + columns: list[int] | None = None, + rows: list[int] | None = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + volume: Dispense volume in microliters (1-3000). + flow_rate: Flow rate ("Low", "Medium", or "High"). + offset_x: X offset in 0.1mm units (-125 to 125). + offset_y: Y offset in 0.1mm units (-40 to 40). + offset_z: Z offset in 0.1mm units (1-1500). Default depends on plate type: + 336 for 96/384-well, 254 for 1536-well. + pre_dispense_volume: Pre-dispense volume in µL (0 to disable). + num_pre_dispenses: Number of pre-dispenses (default 2). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + columns: List of 1-indexed column numbers to dispense to, or None for all. + For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. + rows: List of 1-indexed row group numbers, or None for all. + For 96-well: only 1 (no selection). For 384-well: 1-2. For 1536-well: 1-4. + + Raises: + ValueError: If parameters are invalid. + """ + validate_num_pre_dispenses(num_pre_dispenses) + offset_z, flow_rate_enum, column_mask = self._validate_peristaltic_dispense_params( + volume=volume, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + columns=columns, + rows=rows, + ) + + logger.info( + "Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", + volume, + flow_rate, + cassette, + ) + + data = self._build_peristaltic_dispense_command( + volume=volume, + flow_rate=flow_rate_enum, + cassette=cassette, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + column_mask=column_mask, + rows=rows, + pump=1, + ) + framed_command = build_framed_message(PERISTALTIC_DISPENSE_COMMAND, data) + await self._send_step_command(framed_command) + + async def peristaltic_purge( + self, + volume: float | None = None, + duration: int | None = None, + flow_rate: Literal["Low", "Medium", "High"] = "High", + cassette: Literal["Any", "1uL", "5uL", "10uL"] = "Any", + ) -> None: + """Purge the fluid lines using the peristaltic pump. + + Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. + + PERISTALTIC_PURGE uses the same data format as PERISTALTIC_PRIME + (both send identical data bytes). + + Args: + volume: Purge volume in microliters. + duration: Fixed duration in seconds (alternative to volume). + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Raises: + ValueError: If both volume and duration are specified, or if neither is given. + """ + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + if volume is None and duration is None: + raise ValueError("Either volume or duration must be specified.") + + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + purge_volume = 0.0 + purge_duration = duration + else: + assert volume is not None # guaranteed by the mutual-exclusion check above + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 µL (GUI limit)") + purge_volume = volume + purge_duration = 0 + + validate_peristaltic_flow_rate(flow_rate) + + logger.info( + "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", + purge_volume, + flow_rate, + cassette, + ) + + # Reuse peristaltic_prime builder since data format is identical + data = self._build_peristaltic_prime_command( + volume=purge_volume, + duration=purge_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], + reverse=True, + cassette=cassette, + pump=1, + ) + framed_command = build_framed_message(PERISTALTIC_PURGE_COMMAND, data) + # Timeout: duration (if specified) + buffer for volume-based purging + purge_timeout = self.timeout + purge_duration + 30 + await self._send_step_command(framed_command, timeout=purge_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_peristaltic_prime_command( + self, + volume: float, + duration: int = 0, + flow_rate: int = 2, + reverse: bool = True, + cassette: str = "Any", + pump: int = 1, + ) -> bytes: + """Build peristaltic prime command bytes. + + Protocol format (11 bytes): + Example: 04 2c 01 00 00 02 01 00 01 00 00 + + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1-2] Volume (LE) — 0x0000 when using duration mode + [3-4] Duration in seconds (LE) — 0x0000 when using volume mode + [5] Flow rate enum (0=Low, 1=Medium, 2=High) + [6] Reverse/submerge (0 or 1) + [7] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [8] Pump (Primary: 1, Secondary: 2) + [9-10] Padding (0x0000) + + Args: + volume: Prime volume in microliters (0 when using duration mode). + duration: Fixed duration in seconds (0 when using volume mode). + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + reverse: Whether to reverse/submerge after prime. + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (11 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + dur_low = duration & 0xFF + dur_high = (duration >> 8) & 0xFF + cassette_byte = cassette_to_byte(cassette) + + return bytes( + [ + self.plate_type.value, # Plate type prefix + vol_low, + vol_high, + dur_low, # Duration low byte (0 in volume mode) + dur_high, # Duration high byte + flow_rate, # Flow rate (0=Low, 1=Medium, 2=High) + 1 if reverse else 0, # Reverse/submerge + cassette_byte, # Cassette type + pump & 0xFF, # Pump (1=Primary, 2=Secondary) + 0, + 0, # Padding + ] + ) + + def _build_peristaltic_dispense_command( + self, + volume: float, + flow_rate: int, + cassette: str = "Any", + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: list[int] | None = None, + rows: list[int] | None = None, + pump: int = 1, + ) -> bytes: + """Build peristaltic dispense command bytes. + + Protocol format (24 bytes): + Example: 04 0a 00 02 00 00 00 50 01 0a 00 02 ff ff ff ff ff ff 00 01 00 00 00 00 + + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1-2] Volume (LE) + [3] Flow rate (0=Low, 1=Med, 2=High) + [4] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [5] Offset X (signed byte) + [6] Offset Y (signed byte) + [7-8] Offset Z (LE) + [9-10] Pre-dispense volume (LE, 0 if disabled) + [11] Num pre-dispenses + [12-17] Column mask (48 bits packed, normal: 1=selected) + [18] Row mask (4 bits packed, INVERTED: 0=selected, 1=deselected) + [19] Pump (Primary: 1, Secondary: 2) + [20-23] Padding + + Args: + volume: Dispense volume in microliters. + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pre_dispense_volume: Pre-dispense volume in µL. + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + rows: List of row numbers (1-4) or None for all rows. + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (24 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + z_low = offset_z & 0xFF + z_high = (offset_z >> 8) & 0xFF + pre_disp_low, pre_disp_high = encode_volume_16bit(pre_dispense_volume) + cassette_byte = cassette_to_byte(cassette) + # Pass correct num_row_groups based on plate type + num_row_groups = plate_type_max_rows(self.plate_type) + row_mask_byte = encode_quadrant_mask_inverted(rows, num_row_groups=num_row_groups) + + # Encode column mask (6 bytes) + column_mask_bytes = encode_column_mask(column_mask) + + return ( + bytes( + [ + self.plate_type.value, # Plate type prefix + vol_low, + vol_high, + flow_rate, # Flow rate (0=Low, 1=Medium, 2=High) + cassette_byte, # Cassette type + encode_signed_byte(offset_x), # Offset X + encode_signed_byte(offset_y), # Offset Y + z_low, + z_high, + pre_disp_low, + pre_disp_high, + num_pre_dispenses, # Number of pre-dispenses + ] + ) + + column_mask_bytes + + bytes( + [ + row_mask_byte, # Row mask (inverted: 0=selected) + pump & 0xFF, # Pump (1=Primary, 2=Secondary) + 0, + 0, + 0, + 0, # Padding (4 bytes) + ] + ) + ) diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/plate_washing/biotek/el406/steps/_shake.py new file mode 100644 index 00000000000..01c274d06ee --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/_shake.py @@ -0,0 +1,153 @@ +"""EL406 shake/soak step methods. + +Provides the shake operation and its command builder. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from ..constants import ( + SHAKE_SOAK_COMMAND, +) +from ..helpers import ( + INTENSITY_TO_BYTE, + validate_intensity, +) +from ..protocol import build_framed_message +from ._base import EL406StepsBaseMixin + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class EL406ShakeStepsMixin(EL406StepsBaseMixin): + """Mixin for shake/soak step operations.""" + + MAX_SHAKE_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + MAX_SOAK_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + + async def shake( + self, + duration: int = 0, + intensity: Literal["Variable", "Slow", "Medium", "Fast"] = "Medium", + soak_duration: int = 0, + move_home_first: bool = True, + ) -> None: + """Shake the plate with optional soak period. + + Durations are in whole seconds (GUI uses mm:ss picker, max 59:59 each). + A duration of 0 disables shake. A soak_duration of 0 disables soak. + + Note: The GUI forces move_home_first=True when total time exceeds 60s + to prevent manifold drip contamination. Our default of True matches this. + + Args: + duration: Shake duration in seconds (0-3599). 0 to disable shake. + intensity: Shake intensity - "Variable", "Slow" (3.5 Hz), + "Medium" (5 Hz), or "Fast" (8 Hz). + soak_duration: Soak duration in seconds after shaking (0-3599). 0 to disable. + move_home_first: Move carrier to home position before shaking (default True). + + Raises: + ValueError: If parameters are invalid. + """ + if duration < 0 or duration > self.MAX_SHAKE_DURATION: + raise ValueError(f"Invalid duration {duration}. Must be 0-{self.MAX_SHAKE_DURATION}.") + if soak_duration < 0 or soak_duration > self.MAX_SOAK_DURATION: + raise ValueError( + f"Invalid soak_duration {soak_duration}. Must be 0-{self.MAX_SOAK_DURATION}." + ) + if duration == 0 and soak_duration == 0: + raise ValueError("At least one of duration or soak_duration must be > 0.") + validate_intensity(intensity) + + shake_enabled = duration > 0 + + logger.info( + "Shake: %ds, %s intensity, move_home=%s, soak=%ds", + duration, + intensity, + move_home_first, + soak_duration, + ) + + data = self._build_shake_command( + shake_duration=duration, + soak_duration=soak_duration, + intensity=intensity, + shake_enabled=shake_enabled, + move_home_first=move_home_first, + ) + framed_command = build_framed_message(SHAKE_SOAK_COMMAND, data) + total_timeout = duration + soak_duration + self.timeout + await self._send_step_command(framed_command, timeout=total_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_shake_command( + self, + shake_duration: int = 0, + soak_duration: int = 0, + intensity: str = "medium", + shake_enabled: bool = True, + move_home_first: bool = True, + ) -> bytes: + """Build shake command bytes. + + Byte structure (12 bytes): + [0] Plate type + [1] move_home_first: 0x00 or 0x01 + [2-3] Shake duration in total seconds (16-bit LE) + [4] Intensity: 0x01=Variable, 0x02=Slow, 0x03=Medium, 0x04=Fast + [5] Reserved: 0x00 + [6-7] Soak duration in total seconds (16-bit LE) + [8-11] Padding (4 bytes) + + Args: + shake_duration: Shake duration in seconds. + soak_duration: Soak duration in seconds. + intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + shake_enabled: Whether shake is enabled. When False, shake_duration is not encoded. + move_home_first: Move carrier to home position before shaking (default True). + + Returns: + Command bytes (12 bytes). + """ + # Shake duration as 16-bit little-endian total seconds + # Only encode if shake_enabled=True (sets to 0 when disabled) + if shake_enabled: + shake_total_seconds = int(shake_duration) + else: + shake_total_seconds = 0 + shake_low = shake_total_seconds & 0xFF + shake_high = (shake_total_seconds >> 8) & 0xFF + + # Soak duration as 16-bit little-endian total seconds + soak_total_seconds = int(soak_duration) + soak_low = soak_total_seconds & 0xFF + soak_high = (soak_total_seconds >> 8) & 0xFF + + # Map intensity to byte value + intensity_byte = INTENSITY_TO_BYTE.get(intensity, 0x03) + + byte0 = 0x01 if move_home_first else 0x00 + + return bytes( + [ + self.plate_type.value, # byte[0]: Plate type prefix + byte0, # byte[1]: move_home_first + shake_low, # byte[2]: Shake duration (low byte) + shake_high, # byte[3]: Shake duration (high byte) + intensity_byte, # byte[4]: Frequency/intensity + 0, # byte[5]: Reserved + soak_low, # byte[6]: Soak duration (low byte) + soak_high, # byte[7]: Soak duration (high byte) + 0, + 0, + 0, + 0, # bytes[8-11]: Padding/reserved + ] + ) diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py new file mode 100644 index 00000000000..bcf726a96a9 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py @@ -0,0 +1,350 @@ +"""EL406 syringe pump step methods. + +Provides syringe_dispense and syringe_prime operations +plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from ..constants import ( + SYRINGE_DISPENSE_COMMAND, + SYRINGE_PRIME_COMMAND, +) +from ..helpers import ( + columns_to_column_mask, + encode_column_mask, + encode_signed_byte, + encode_volume_16bit, + plate_type_well_count, + syringe_to_byte, + validate_num_pre_dispenses, + validate_offset_xy, + validate_offset_z, + validate_pump_delay, + validate_submerge_duration, + validate_syringe, + validate_syringe_flow_rate, + validate_syringe_volume, + validate_volume, +) +from ..protocol import build_framed_message +from ._base import EL406StepsBaseMixin + +logger = logging.getLogger("pylabrobot.plate_washing.biotek.el406") + + +class EL406SyringeStepsMixin(EL406StepsBaseMixin): + """Mixin for syringe pump step operations.""" + + async def syringe_dispense( + self, + volume: float, + syringe: Literal["A", "B", "Both"] = "A", + flow_rate: int = 2, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay: int = 0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + columns: list[int] | None = None, + ) -> None: + """Dispense liquid using the syringe pump. + + Args: + volume: Dispense volume in microliters per well. + Volume range depends on plate type: + - 96-well: 10-3000 µL + - 384-well: 5-1500 µL + - 1536-well: 3-3000 µL + syringe: Syringe selection — "A", "B", or "Both". + flow_rate: Flow rate (1-5). Maximum rate depends on volume and plate type. + For 96-well: rate 1 for 10+ µL, rate 2 for 20+ µL, rate 3 for 50+ µL, + rate 4 for 60+ µL, rate 5 for 80+ µL. + For 384-well: rate 1 for 5+ µL, rate 2 for 10+ µL, rate 3 for 25+ µL, + rate 4 for 30+ µL, rate 5 for 40+ µL. + For 1536-well: all rates for 3+ µL. + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units, default 336 for 96-well, 254 for 1536-well). + pump_delay: Post-dispense delay in milliseconds (0-5000). + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in µL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses (default 2). + columns: List of 1-indexed column numbers to dispense to, or None for all columns. + For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. + + Raises: + ValueError: If parameters are invalid. + """ + validate_volume(volume) + validate_syringe(syringe) + validate_syringe_flow_rate(flow_rate) + validate_offset_xy(offset_x, "offset_x") + validate_offset_xy(offset_y, "offset_y") + validate_offset_z(offset_z, "offset_z") + validate_pump_delay(pump_delay) + validate_num_pre_dispenses(num_pre_dispenses) + + column_mask = columns_to_column_mask( + columns, plate_wells=plate_type_well_count(self.plate_type) + ) + + logger.info( + "Syringe dispense: %.1f uL from syringe %s, flow rate %d", + volume, + syringe, + flow_rate, + ) + + data = self._build_syringe_dispense_command( + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pump_delay=pump_delay, + pre_dispense=pre_dispense, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + column_mask=column_mask, + ) + framed_command = build_framed_message(SYRINGE_DISPENSE_COMMAND, data) + await self._send_step_command(framed_command) + + async def syringe_prime( + self, + syringe: Literal["A", "B"] = "A", + volume: float = 5000.0, + flow_rate: int = 5, + refills: int = 2, + pump_delay: int = 0, + submerge_tips: bool = True, + submerge_duration: int = 0, + ) -> None: + """Prime the syringe pump system. + + Fills the syringe tubing by drawing and expelling liquid. + + Args: + syringe: Syringe selection — "A" or "B". + volume: Volume to prime in microliters (80-9999). + flow_rate: Flow rate (1-5). + refills: Number of prime cycles (1-255). + pump_delay: Delay between cycles in milliseconds (0-5000). + submerge_tips: Submerge tips in fluid after prime (default True). + submerge_duration: Submerge duration in minutes (0-1439, i.e. up to 23:59). + 0 to disable submerge time. Only encoded when submerge_tips=True. + + Raises: + ValueError: If parameters are invalid. + """ + validate_syringe(syringe) + validate_syringe_volume(volume) + validate_syringe_flow_rate(flow_rate) + validate_pump_delay(pump_delay) + validate_submerge_duration(submerge_duration) + if not 1 <= refills <= 255: + raise ValueError(f"refills must be 1-255, got {refills}") + + logger.info( + "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", + syringe, + volume, + flow_rate, + refills, + ) + + data = self._build_syringe_prime_command( + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + refills=refills, + pump_delay=pump_delay, + submerge_tips=submerge_tips, + submerge_duration=submerge_duration, + ) + framed_command = build_framed_message(SYRINGE_PRIME_COMMAND, data) + # Timeout: base for priming + submerge duration (in minutes) + buffer + prime_timeout = self.timeout + (submerge_duration * 60) + 30 + await self._send_step_command(framed_command, timeout=prime_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_syringe_dispense_command( + self, + volume: float, + syringe: str, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay: int = 0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: list[int] | None = None, + ) -> bytes: + """Build syringe dispense command bytes. + + Wire format (26 bytes): + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1, Both=2 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Offset X: signed byte + [6] Offset Y: signed byte + [7-8] Offset Z: 2 bytes, little-endian + [9-10] Pump delay: 2 bytes, little-endian, in ms + [11-12] Pre-dispense volume: 2 bytes, little-endian (0 if pre_dispense=False) + [13] Number of pre-dispenses (default 2) + [14-19] Column mask: 6 bytes (48 bits packed) + [20] Bottle selection (A→0, B→2, Both→4) + [21-25] Padding (5 bytes) + + Args: + volume: Dispense volume in microliters. + syringe: Syringe selection (A, B, Both). + flow_rate: Flow rate (1-5). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pump_delay: Post-dispense delay in milliseconds. + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in µL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + + Returns: + Command bytes (26 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + z_low = offset_z & 0xFF + z_high = (offset_z >> 8) & 0xFF + delay_low = pump_delay & 0xFF + delay_high = (pump_delay >> 8) & 0xFF + + # Pre-dispense volume: only encode if pre-dispense is enabled + if pre_dispense: + pre_disp_vol_int = int(pre_dispense_volume) + else: + pre_disp_vol_int = 0 + pre_disp_vol_low = pre_disp_vol_int & 0xFF + pre_disp_vol_high = (pre_disp_vol_int >> 8) & 0xFF + + # Encode column mask (6 bytes) + column_mask_bytes = encode_column_mask(column_mask) + + # Bottle selection based on syringe: A→0, B→2, Both→4 + _SYRINGE_TO_BOTTLE = {"A": 0, "B": 2, "BOTH": 4} + bottle_byte = _SYRINGE_TO_BOTTLE.get(syringe.upper(), 0) + + return ( + bytes( + [ + self.plate_type.value, # Plate type prefix + syringe_to_byte(syringe), + vol_low, + vol_high, + flow_rate, + encode_signed_byte(offset_x), + encode_signed_byte(offset_y), + z_low, + z_high, + delay_low, + delay_high, + pre_disp_vol_low, + pre_disp_vol_high, + num_pre_dispenses, # Number of pre-dispenses (default 2) + ] + ) + + column_mask_bytes + + bytes( + [ + bottle_byte, # Bottle selection + 0, + 0, + 0, + 0, + 0, # Padding (5 bytes) + ] + ) + ) + + def _build_syringe_prime_command( + self, + volume: float, + syringe: str, + flow_rate: int, + refills: int = 2, + pump_delay: int = 0, + submerge_tips: bool = True, + submerge_duration: int = 0, + ) -> bytes: + """Build syringe prime command bytes. + + Protocol format (13 bytes): + [0] Plate type (EL406PlateType enum value, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Refills: byte (number of prime cycles) + [6-7] Pump delay: 2 bytes, little-endian, in ms + [8] Submerge tips (0 or 1) — "Submerge tips in fluid after prime" + [9-10] Submerge duration in minutes (LE uint16). 0 if submerge_tips=False. + [11] Bottle: derived from syringe (A->0, B->2) + [12] Padding + + Args: + volume: Prime volume in microliters. + syringe: Syringe selection (A, B). + flow_rate: Flow rate (1-5). + refills: Number of prime cycles. + pump_delay: Delay between cycles in milliseconds (default 0). + submerge_tips: Submerge tips in fluid after prime (default True). + submerge_duration: Submerge duration in minutes (0-1439). Only encoded + when submerge_tips=True. + + Returns: + Command bytes (13 bytes). + """ + vol_low, vol_high = encode_volume_16bit(volume) + delay_low = pump_delay & 0xFF + delay_high = (pump_delay >> 8) & 0xFF + + # Submerge time: only encode when submerge_tips is enabled + if submerge_tips and submerge_duration > 0: + sub_total = submerge_duration & 0xFFFF + else: + sub_total = 0 + sub_low = sub_total & 0xFF + sub_high = (sub_total >> 8) & 0xFF + + # Bottle selection: A→0, B→2 + _SYRINGE_TO_BOTTLE = {"A": 0, "B": 2} + bottle_byte = _SYRINGE_TO_BOTTLE.get(syringe.upper(), 0) + + return bytes( + [ + self.plate_type.value, # Plate type prefix + syringe_to_byte(syringe), # Syringe index (A=0, B=1) + vol_low, + vol_high, + flow_rate, + refills & 0xFF, + delay_low, + delay_high, + 1 if submerge_tips else 0, + sub_low, # Submerge duration low byte (minutes) + sub_high, # Submerge duration high byte (minutes) + bottle_byte, # Bottle selection + 0, # Padding + ] + ) diff --git a/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py new file mode 100644 index 00000000000..b51e9ac09c7 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py @@ -0,0 +1,221 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Aspirate operations. + +This module contains tests for aspirate-related step methods: +- aspirate (M_ASPIRATE) +- strip_aspirate (M_ASPIRATE_STRIP) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendAspirate(unittest.IsolatedAsyncioTestCase): + """Test EL406 aspirate functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_aspirate_sends_command(self): + """Aspirate should send correct command.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_aspirate() + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_aspirate_with_travel_rate(self): + """Aspirate should accept string travel rate.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_aspirate(travel_rate="5") + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_aspirate_with_cell_wash_rate(self): + """Aspirate should accept cell wash travel rate.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_aspirate(travel_rate="2 CW") + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_aspirate_validates_travel_rate(self): + """Aspirate should reject invalid travel rate strings.""" + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(travel_rate="10") + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(travel_rate="5 CW") + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(travel_rate="bad") + + async def test_aspirate_validates_delay_ms(self): + """Aspirate delay must be 0-5000 ms.""" + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(delay_ms=5001) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(delay_ms=-1) + + async def test_aspirate_validates_vacuum_time(self): + """Vacuum filtration time must be 5-999 seconds.""" + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(vacuum_filtration=True, vacuum_time_sec=4) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(vacuum_filtration=True, vacuum_time_sec=1000) + + async def test_aspirate_validates_offsets(self): + """Aspirate should validate X/Y/Z offset ranges.""" + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_x=61) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_x=-61) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_y=41) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_y=-41) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_z=0) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(offset_z=211) + + async def test_aspirate_validates_secondary_offsets(self): + """Secondary aspirate offsets should be validated when enabled.""" + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(secondary_aspirate=True, secondary_x=61) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(secondary_aspirate=True, secondary_y=-41) + with self.assertRaises(ValueError): + await self.backend.manifold_aspirate(secondary_aspirate=True, secondary_z=0) + + async def test_aspirate_vacuum_filtration(self): + """Aspirate with vacuum filtration should send command.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_aspirate(vacuum_filtration=True, vacuum_time_sec=30) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestAspirateCommandEncoding(unittest.TestCase): + """Test aspirate command binary encoding. + + Wire format (22 bytes): + [0] plate type prefix (0x04=96-well) + [1] vacuum_filtration + [2-3] time_value (delay_ms or vacuum_time_sec) LE + [4] travel_rate byte + [5] x_offset (signed byte) + [6] y_offset (signed byte) + [7-8] z_offset LE + [9] secondary_mode + [10] secondary_x (signed byte) + [11] secondary_y (signed byte) + [12-13] secondary_z LE + [14-15] reserved + [16-17] column_mask + [18-21] padding + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_aspirate_command_defaults(self): + """Default aspirate: no vacuum, rate 3, delay 0, z=30.""" + cmd = self.backend._build_aspirate_command() + self.assertEqual(len(cmd), 22) + self.assertEqual(cmd[0], 0x04) + self.assertEqual(cmd[1], 0) # no vacuum + self.assertEqual(cmd[2], 0) # delay low + self.assertEqual(cmd[3], 0) # delay high + self.assertEqual(cmd[4], 3) # travel rate "3" -> byte 3 + self.assertEqual(cmd[5], 0) # x + self.assertEqual(cmd[6], 0) # y + self.assertEqual(cmd[7], 30) # z low + self.assertEqual(cmd[8], 0) # z high + self.assertEqual(cmd[9], 0) # secondary mode None + self.assertEqual(cmd[10], 0) # sec x + self.assertEqual(cmd[11], 0) # sec y + self.assertEqual(cmd[12], 30) # sec z low + self.assertEqual(cmd[13], 0) # sec z high + self.assertEqual(cmd[14], 0) # reserved + self.assertEqual(cmd[15], 0) # reserved + self.assertEqual(cmd[16], 0xFF) # well mask (all 12 cols) + self.assertEqual(cmd[17], 0x0F) + self.assertEqual(cmd[18:22], bytes(4)) # padding + + def test_aspirate_command_vacuum_filtration(self): + """Vacuum filtration flag at byte 1.""" + cmd = self.backend._build_aspirate_command(vacuum_filtration=True, time_value=30) + self.assertEqual(cmd[1], 1) + # time_value=30 at bytes 2-3 + self.assertEqual(cmd[2], 30) + self.assertEqual(cmd[3], 0) + + def test_aspirate_command_delay_encoding(self): + """Delay encoded as LE uint16 at bytes 2-3.""" + cmd = self.backend._build_aspirate_command(time_value=5000) + # 5000 = 0x1388 + self.assertEqual(cmd[2], 0x88) + self.assertEqual(cmd[3], 0x13) + + def test_aspirate_command_travel_rate(self): + """Travel rate byte at position 4.""" + # Normal rate "5" -> byte 5 + cmd = self.backend._build_aspirate_command(travel_rate_byte=5) + self.assertEqual(cmd[4], 5) + # CW rate "2 CW" -> byte 8 + cmd = self.backend._build_aspirate_command(travel_rate_byte=8) + self.assertEqual(cmd[4], 8) + + def test_aspirate_command_negative_offset_x(self): + """X offset at byte 5, signed byte encoding.""" + cmd = self.backend._build_aspirate_command(offset_x=-30) + # -30 as unsigned byte = 226 = 0xE2 + self.assertEqual(cmd[5], 226) + + def test_aspirate_command_positive_offset_y(self): + """Y offset at byte 6.""" + cmd = self.backend._build_aspirate_command(offset_y=5) + self.assertEqual(cmd[6], 5) + + def test_aspirate_command_z_offset(self): + """Z offset as LE uint16 at bytes 7-8.""" + cmd = self.backend._build_aspirate_command(offset_z=121) + self.assertEqual(cmd[7], 121) + self.assertEqual(cmd[8], 0) + + def test_aspirate_command_secondary_mode(self): + """Secondary mode byte at position 9.""" + cmd = self.backend._build_aspirate_command(secondary_mode=1) + self.assertEqual(cmd[9], 1) + + def test_aspirate_command_secondary_offsets(self): + """Secondary X/Y/Z offsets at positions 10-13.""" + cmd = self.backend._build_aspirate_command( + secondary_x=-5, + secondary_y=3, + secondary_z=45, + ) + # sec_x = -5 -> 0xFB + self.assertEqual(cmd[10], 0xFB) + self.assertEqual(cmd[11], 3) + self.assertEqual(cmd[12], 45) + self.assertEqual(cmd[13], 0) + + def test_aspirate_command_column_mask_all(self): + """Column mask at bytes 16-17 is always all-selected for manifold aspirate.""" + cmd = self.backend._build_aspirate_command() + self.assertEqual(cmd[16], 0xFF) # all 12 columns + self.assertEqual(cmd[17], 0x0F) + + def test_aspirate_command_length(self): + """Aspirate command should be exactly 22 bytes.""" + cmd = self.backend._build_aspirate_command() + self.assertEqual(len(cmd), 22) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py new file mode 100644 index 00000000000..740ad81e341 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py @@ -0,0 +1,282 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Dispense operations. + +This module contains tests for dispense-related step methods: +- dispense (M_DISPENSE) +- syringe_dispense (S_DISPENSE) +- strip_dispense (M_DISPENSE_STRIP) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendDispense(unittest.IsolatedAsyncioTestCase): + """Test EL406 manifold dispense functionality. + + Parameter limits: + Volume: 25-3000 uL (manifold-dependent: 50 for 96-tube, 25 for 192/128-tube) + Flow rate: 3-11 + X offset: -60..60 + Y offset: -40..40 + Z offset: 1-210 + Pre-dispense volume: 25-3000 (when enabled) + Pre-dispense flow rate: 3-11 + Vacuum delay volume: 0-3000 + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_dispense_sends_command(self): + """Dispense should send correct command.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_dispense(volume=300.0, buffer="A", flow_rate=5) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_dispense_validates_volume(self): + """Dispense should validate volume range (25-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=0.0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=24.0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=3001.0) + + async def test_dispense_validates_flow_rate(self): + """Dispense should validate flow rate (1-11).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, flow_rate=0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, flow_rate=12) + + async def test_dispense_flow_rate_1_2_requires_vacuum_delay(self): + """Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0.""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, flow_rate=1) # no vacuum + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, flow_rate=2) # no vacuum + # With vacuum delay, should work + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=300.0, flow_rate=1, vacuum_delay_volume=100.0) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=300.0, flow_rate=2, vacuum_delay_volume=100.0) + + async def test_dispense_validates_offset_x(self): + """Dispense should validate X offset (-60..60).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_x=-61) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_x=61) + + async def test_dispense_validates_offset_y(self): + """Dispense should validate Y offset (-40..40).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_y=-41) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_y=41) + + async def test_dispense_validates_offset_z(self): + """Dispense should validate Z offset (1-210).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_z=0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, offset_z=211) + + async def test_dispense_validates_pre_dispense_volume(self): + """Dispense should validate pre-dispense volume (0 or 25-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, pre_dispense_volume=10.0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, pre_dispense_volume=3001.0) + + async def test_dispense_validates_pre_dispense_flow_rate(self): + """Dispense should validate pre-dispense flow rate (3-11).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, pre_dispense_flow_rate=2) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, pre_dispense_flow_rate=12) + + async def test_dispense_validates_vacuum_delay_volume(self): + """Dispense should validate vacuum delay volume (0-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, vacuum_delay_volume=-1.0) + with self.assertRaises(ValueError): + await self.backend.manifold_dispense(volume=300.0, vacuum_delay_volume=3001.0) + + async def test_dispense_accepts_flow_rate_range(self): + """Dispense should accept flow rates 1-11 (1-2 with vacuum delay).""" + # Flow rates 3-11 work without vacuum + for flow_rate in range(3, 12): + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=300.0, flow_rate=flow_rate) + # Flow rates 1-2 work with vacuum delay + for flow_rate in [1, 2]: + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense( + volume=300.0, flow_rate=flow_rate, vacuum_delay_volume=100.0 + ) + + async def test_dispense_accepts_all_buffers(self): + """Dispense should accept buffers A, B, C, D.""" + for buffer in ["A", "B", "C", "D"]: + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=300.0, buffer=buffer) + + async def test_dispense_accepts_volume_boundaries(self): + """Dispense should accept volume at boundaries (25 and 3000).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=25.0) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=3000.0) + + async def test_dispense_accepts_pre_dispense_zero(self): + """Pre-dispense volume of 0 should be accepted (disabled).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.manifold_dispense(volume=300.0, pre_dispense_volume=0.0) + + async def test_dispense_raises_when_device_not_initialized(self): + """Dispense should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.manifold_dispense(volume=300.0) + + async def test_dispense_raises_on_timeout(self): + """Dispense should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") + with self.assertRaises(TimeoutError): + await self.backend.manifold_dispense(volume=300.0) + + +class TestEL406BackendSyringeDispense(unittest.IsolatedAsyncioTestCase): + """Test EL406 syringe dispense functionality. + + The syringe dispense operation uses the syringe pump to dispense liquid + to wells. This provides more precise volume control than peristaltic + dispensing. + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_syringe_dispense_sends_command(self): + """syringe_dispense should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.syringe_dispense(volume=50.0, syringe="A", flow_rate=2) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_syringe_dispense_validates_volume(self): + """syringe_dispense should validate volume is positive.""" + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=0.0, syringe="A") + + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=-100.0, syringe="A") + + async def test_syringe_dispense_validates_syringe(self): + """syringe_dispense should validate syringe selection.""" + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="Z") + + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="C") + + async def test_syringe_dispense_validates_flow_rate(self): + """syringe_dispense should validate flow rate (1-5).""" + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", flow_rate=0) + + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", flow_rate=6) + + async def test_syringe_dispense_validates_pump_delay(self): + """syringe_dispense should validate pump_delay (0-5000).""" + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", pump_delay=-1) + + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", pump_delay=5001) + + async def test_syringe_dispense_raises_when_device_not_initialized(self): + """syringe_dispense should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.syringe_dispense(volume=50.0, syringe="A") + + async def test_syringe_dispense_accepts_flow_rate_range(self): + """syringe_dispense should accept flow rates 1-5.""" + for flow_rate in range(1, 6): + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_dispense(volume=50.0, syringe="A", flow_rate=flow_rate) + + async def test_syringe_dispense_accepts_pump_delay_range(self): + """syringe_dispense should accept pump_delay 0-5000.""" + for pump_delay in [0, 100, 5000]: + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_dispense(volume=50.0, syringe="A", pump_delay=pump_delay) + + async def test_syringe_dispense_with_offsets(self): + """syringe_dispense should accept X, Y, Z offsets.""" + initial_count = len(self.backend.io.written_data) + await self.backend.syringe_dispense( + volume=50.0, + syringe="A", + flow_rate=2, + offset_x=10, + offset_z=336, + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_syringe_dispense_with_columns(self): + """syringe_dispense should accept column list (1-indexed).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_dispense( + volume=50.0, + syringe="A", + columns=[1, 2, 3], + ) + + async def test_syringe_dispense_columns_none_means_all(self): + """columns=None should select all columns.""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_dispense(volume=50.0, syringe="A", columns=None) + + async def test_syringe_dispense_validates_columns(self): + """syringe_dispense should validate column numbers.""" + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", columns=[0]) + + with self.assertRaises(ValueError): + await self.backend.syringe_dispense(volume=50.0, syringe="A", columns=[13]) + + async def test_syringe_dispense_raises_on_timeout(self): + """syringe_dispense should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No ACK response + with self.assertRaises(TimeoutError): + await self.backend.syringe_dispense(volume=50.0, syringe="A") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py new file mode 100644 index 00000000000..45168e1930c --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py @@ -0,0 +1,610 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Peristaltic pump operations. + +This module contains tests for peristaltic pump-related step methods: +- peristaltic_dispense (P_DISPENSE) +- peristaltic_purge (P_PURGE) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, + EL406PlateType, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendPeristalticDispense(unittest.IsolatedAsyncioTestCase): + """Test EL406 peristaltic dispense functionality. + + The peristaltic dispense operation (ePDispense = 1) uses the peristaltic pump + to dispense liquid to wells. This is different from manifold dispense (M_DISPENSE). + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_peristaltic_dispense_sends_command(self): + """peristaltic_dispense should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.peristaltic_dispense(volume=300.0, flow_rate="Medium") + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_peristaltic_dispense_validates_volume(self): + """peristaltic_dispense should validate volume is positive.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_dispense(volume=0.0) + + with self.assertRaises(ValueError): + await self.backend.peristaltic_dispense(volume=-100.0) + + async def test_peristaltic_dispense_accepts_various_flow_rates(self): + """peristaltic_dispense should accept all valid flow rate strings.""" + for fr in ["Low", "Medium", "High"]: + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_dispense(volume=300.0, flow_rate=fr) + + async def test_peristaltic_dispense_raises_when_device_not_initialized(self): + """peristaltic_dispense should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.peristaltic_dispense(volume=300.0) + + async def test_peristaltic_dispense_with_pre_dispense_volume(self): + """peristaltic_dispense should accept optional pre-dispense volume.""" + initial_count = len(self.backend.io.written_data) + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + pre_dispense_volume=50.0, + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_peristaltic_dispense_with_offsets(self): + """peristaltic_dispense should accept X, Y, Z offsets.""" + initial_count = len(self.backend.io.written_data) + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + offset_x=10, + offset_y=-5, + offset_z=336, + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestPeristalticDispenseCommandEncoding(unittest.TestCase): + """Test peristaltic dispense command binary encoding. + + Protocol format for peristaltic dispense (P_DISPENSE = 1): + [0] Step type: 0x01 (P_DISPENSE) + [1-2] Volume: 2 bytes, little-endian, in uL + [3] Buffer valve: A=0, B=1, C=2, D=3 + [4] Cassette type: byte (default 0) + [5] Offset X: signed byte (-128 to +127) + [6] Offset Y: signed byte (-128 to +127) + [7-8] Offset Z: 2 bytes, little-endian + [9-10] Prime volume: 2 bytes, little-endian + [11] Flow rate: 1-9 + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_peristaltic_dispense_step_type(self): + """Peristaltic dispense command should have step type prefix 0x04.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + ) + + self.assertEqual(cmd[0], 0x04) + + def test_peristaltic_dispense_volume_encoding(self): + """Peristaltic dispense should encode volume as little-endian 2 bytes.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + ) + + # Volume: 300 uL = 0x012C little-endian = [0x2C, 0x01] + self.assertEqual(cmd[1], 0x2C) + self.assertEqual(cmd[2], 0x01) + + def test_peristaltic_dispense_volume_1000ul(self): + """Peristaltic dispense with 1000 uL.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=1000.0, + flow_rate=5, + ) + + # Volume: 1000 uL = 0x03E8 little-endian = [0xE8, 0x03] + self.assertEqual(cmd[1], 0xE8) + self.assertEqual(cmd[2], 0x03) + + def test_peristaltic_dispense_flow_rate_at_byte3(self): + """Peristaltic dispense flow rate should be at byte 3.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + ) + + self.assertEqual(cmd[3], 5) + + def test_peristaltic_dispense_cassette_at_byte4(self): + """Peristaltic dispense cassette type should be at byte 4.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + cassette="5uL", + ) + + # 5uL cassette = 2 + self.assertEqual(cmd[4], 2) + + def test_peristaltic_dispense_offset_z(self): + """Peristaltic dispense should encode Z offset as little-endian 2 bytes.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + offset_z=336, + ) + + # Offset Z: 336 = 0x0150 little-endian = [0x50, 0x01] + self.assertEqual(cmd[7], 0x50) + self.assertEqual(cmd[8], 0x01) + + def test_peristaltic_dispense_offset_x_positive(self): + """Peristaltic dispense should encode positive X offset at byte 5.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + offset_x=50, + ) + + self.assertEqual(cmd[5], 50) + + def test_peristaltic_dispense_offset_x_negative(self): + """Peristaltic dispense should encode negative X offset as two's complement at byte 5.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + offset_x=-30, + ) + + # -30 as unsigned byte = 256 - 30 = 226 = 0xE2 + self.assertEqual(cmd[5], 226) + + def test_peristaltic_dispense_offset_y_negative(self): + """Peristaltic dispense should encode negative Y offset as two's complement at byte 6.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + offset_y=-20, + ) + + # -20 as unsigned byte = 256 - 20 = 236 = 0xEC + self.assertEqual(cmd[6], 236) + + def test_peristaltic_dispense_pre_dispense_volume(self): + """Peristaltic dispense should encode prime volume as little-endian 2 bytes.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + pre_dispense_volume=50.0, + ) + + # Prime volume: 50 uL = 0x0032 little-endian = [0x32, 0x00] + self.assertEqual(cmd[9], 0x32) + self.assertEqual(cmd[10], 0x00) + + def test_peristaltic_dispense_num_pre_dispenses_default(self): + """Peristaltic dispense should encode default num_pre_dispenses (2) at byte 11.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=7, + ) + + # num_pre_dispenses default: 2 + self.assertEqual(cmd[11], 2) + + def test_peristaltic_dispense_num_pre_dispenses_1(self): + """Peristaltic dispense should encode num_pre_dispenses=1 at byte 11.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=1, + num_pre_dispenses=1, + ) + + self.assertEqual(cmd[11], 1) + + def test_peristaltic_dispense_num_pre_dispenses_5(self): + """Peristaltic dispense should encode num_pre_dispenses=5 at byte 11.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=9, + num_pre_dispenses=5, + ) + + self.assertEqual(cmd[11], 5) + + def test_peristaltic_dispense_full_command(self): + """Test complete peristaltic dispense command with all parameters. + + Wire format: + [0] plate type prefix (0x04=96-well) (step type marker) + [1-2] Volume: 2 bytes, little-endian, in uL + [3] Flow rate + [4] Offset X: signed byte + [5] Offset Y: signed byte + [6] Reserved + [7-8] Offset Z: 2 bytes, little-endian + [9-10] Pre-dispense volume: 2 bytes, little-endian + [11] Number of pre-dispenses + [12-17] Well mask: 6 bytes + [18] Reserved + [19] Quadrant + """ + cmd = self.backend._build_peristaltic_dispense_command( + volume=500.0, + flow_rate=7, + offset_x=10, + offset_y=-5, + offset_z=400, + pre_dispense_volume=25.0, + ) + + self.assertEqual(cmd[0], 0x04) # Step type prefix + self.assertEqual(cmd[1], 0xF4) # Volume low byte (500 = 0x01F4) + self.assertEqual(cmd[2], 0x01) # Volume high byte + self.assertEqual(cmd[3], 7) # Flow rate + self.assertEqual(cmd[4], 0) # Cassette (default Any=0) + self.assertEqual(cmd[5], 10) # Offset X + self.assertEqual(cmd[6], 251) # Offset Y (-5 as unsigned = 251) + self.assertEqual(cmd[7], 0x90) # Offset Z low byte (400 = 0x0190) + self.assertEqual(cmd[8], 0x01) # Offset Z high byte + self.assertEqual(cmd[9], 25) # Pre-dispense volume low byte + self.assertEqual(cmd[10], 0) # Pre-dispense volume high byte + self.assertEqual(cmd[11], 2) # Number of pre-dispenses (default) + + +class TestPeristalticDispenseColumnsAndRows(unittest.IsolatedAsyncioTestCase): + """Test peristaltic_dispense with columns and rows parameters.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_peristaltic_dispense_accepts_columns(self): + """peristaltic_dispense should accept columns parameter (1-indexed).""" + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + columns=[1, 2, 3, 4], + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_dispense_accepts_all_columns_96well(self): + """peristaltic_dispense should accept columns 1-12 for 96-well plate.""" + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + columns=list(range(1, 13)), + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_dispense_validates_column_range_96well(self): + """peristaltic_dispense should reject column 0 and 13 for 96-well plate.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_dispense( + volume=300.0, + columns=[0], # Invalid — 1-indexed + ) + with self.assertRaises(ValueError): + await self.backend.peristaltic_dispense( + volume=300.0, + columns=[13], # Invalid — max 12 for 96-well + ) + + async def test_peristaltic_dispense_none_columns_means_all(self): + """peristaltic_dispense with columns=None should dispense to all columns.""" + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + columns=None, + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_dispense_validates_row_range_96well(self): + """peristaltic_dispense should reject row > 1 for 96-well plate.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_dispense( + volume=300.0, + rows=[2], # Invalid — only 1 row group for 96-well + ) + + async def test_peristaltic_dispense_accepts_row_1_96well(self): + """peristaltic_dispense should accept row 1 for 96-well plate.""" + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + rows=[1], + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_dispense_default_z_96well(self): + """peristaltic_dispense should default offset_z to 336 for 96-well.""" + # offset_z=None → uses plate_type_default_z → 336 for 96-well + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_dispense_columns_and_rows(self): + """peristaltic_dispense should accept both columns and rows.""" + await self.backend.peristaltic_dispense( + volume=300.0, + flow_rate="Medium", + columns=[1, 3, 5], + rows=[1], + ) + self.assertGreater(len(self.backend.io.written_data), 0) + + +class TestPeristalticDispenseCommandEncodingWithMasks(unittest.TestCase): + """Test peristaltic dispense command encoding with well and row masks. + + Protocol format: + [0] plate type prefix (0x04=96-well) + [1-2] Volume (LE) + [3] Flow rate (0=Low, 1=Med, 2=High) + [4] Cassette type + [5] Offset X (signed byte) + [6] Offset Y (signed byte) + [7-8] Offset Z (LE) + [9-10] Pre-dispense volume (LE) + [11] Num pre-dispenses + [12-17] Well mask: 6 bytes (48 bits, 1=selected) + [18] Row mask: 1 byte (4 bits, INVERTED: 0=selected) + [19] Pump (1=Primary, 2=Secondary) + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_peristaltic_dispense_command_with_column_mask_length(self): + """Command with well mask should be 24 bytes.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + column_mask=[0, 1, 2, 3], + ) + self.assertEqual(len(cmd), 24) + + def test_peristaltic_dispense_command_column_mask_encoding(self): + """Command should correctly encode well mask at bytes 12-17.""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + column_mask=[0, 1, 2, 3], + ) + + # Wells 0-3 = bits 0-3 of byte 12 = 0x0F + self.assertEqual(cmd[12], 0x0F) + self.assertEqual(cmd[13:18], bytes([0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_peristaltic_dispense_command_pump_at_byte19(self): + """Pump should be at byte 19 (1=Primary, 2=Secondary).""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + pump=2, # Secondary + ) + self.assertEqual(cmd[19], 2) + + def test_peristaltic_dispense_command_none_column_mask_all_wells(self): + """Command with None column_mask should encode all wells (0xFF * 6).""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + column_mask=None, + ) + self.assertEqual(cmd[12:18], bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) + + def test_peristaltic_dispense_command_default_row_mask(self): + """Default rows=None should encode 0x00 (all selected, inverted).""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + ) + self.assertEqual(cmd[18], 0x00) + + def test_peristaltic_dispense_command_default_pump(self): + """Default pump should be 1 (Primary).""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + ) + self.assertEqual(cmd[19], 1) + + def test_peristaltic_dispense_command_empty_column_mask(self): + """Command with empty column_mask should encode no wells (0x00 * 6).""" + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + column_mask=[], + ) + self.assertEqual(cmd[12:18], bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + + def test_peristaltic_dispense_command_rows_inverted_encoding(self): + """Row mask uses inverted encoding: 0=selected, 1=deselected.""" + # Use 1536-well plate type which supports 4 row groups + self.backend.plate_type = EL406PlateType.PLATE_1536_WELL + # Select rows 1 and 2 → bits 0,1 cleared, bits 2,3 set → 0x0C + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + rows=[1, 2], + ) + self.assertEqual(cmd[18], 0x0C) + + def test_peristaltic_dispense_command_complex_column_mask(self): + """Command with complex well mask spanning multiple bytes.""" + # Wells 0, 8, 16, 24, 32, 40 = bit 0 of each of the 6 bytes + cmd = self.backend._build_peristaltic_dispense_command( + volume=300.0, + flow_rate=5, + column_mask=[0, 8, 16, 24, 32, 40], + ) + + self.assertEqual(cmd[12], 0x01) + self.assertEqual(cmd[13], 0x01) + self.assertEqual(cmd[14], 0x01) + self.assertEqual(cmd[15], 0x01) + self.assertEqual(cmd[16], 0x01) + self.assertEqual(cmd[17], 0x01) + + def test_peristaltic_dispense_command_both_masks(self): + """Command with column_mask and rows.""" + # Use 1536-well plate type which supports 4 row groups + self.backend.plate_type = EL406PlateType.PLATE_1536_WELL + cmd = self.backend._build_peristaltic_dispense_command( + volume=500.0, + flow_rate=7, + column_mask=[0, 47], # First and last wells + rows=[1, 2, 3, 4], # All rows selected + pump=2, + ) + + self.assertEqual(cmd[0], 0x00) # 1536-well plate type + self.assertEqual(cmd[3], 7) # Flow rate + + # Well mask: well 0 = bit 0 of byte 12, well 47 = bit 7 of byte 17 + self.assertEqual(cmd[12], 0x01) + self.assertEqual(cmd[13:17], bytes([0x00, 0x00, 0x00, 0x00])) + self.assertEqual(cmd[17], 0x80) + + # Row mask: all 4 rows selected → inverted = 0x00 + self.assertEqual(cmd[18], 0x00) + # Pump at byte 19 + self.assertEqual(cmd[19], 2) + + +class TestEL406BackendPeristalticPurge(unittest.IsolatedAsyncioTestCase): + """Test EL406 peristaltic purge functionality. + + The peristaltic purge operation uses the peristaltic pump to expel/clear + liquid from the fluid lines. This is used for cleaning or changing buffers. + + Current API: + peristaltic_purge(volume, flow_rate="High", cassette="Any") + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_peristaltic_purge_sends_command(self): + """peristaltic_purge should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.peristaltic_purge(volume=1000.0, flow_rate="High") + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_peristaltic_purge_validates_volume(self): + """peristaltic_purge should validate volume range (1-3000 µL).""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(volume=0.0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(volume=-100.0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(volume=3001.0) + + async def test_peristaltic_purge_accepts_volume_boundaries(self): + """peristaltic_purge should accept volume at boundaries (1, 3000).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_purge(volume=1.0) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_purge(volume=3000.0) + + async def test_peristaltic_purge_validates_duration(self): + """peristaltic_purge should validate duration range (1-300 seconds).""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(duration=0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(duration=-1) + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(duration=301) + + async def test_peristaltic_purge_accepts_duration_boundaries(self): + """peristaltic_purge should accept duration at boundaries (1, 300).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_purge(duration=1) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_purge(duration=300) + + async def test_peristaltic_purge_rejects_both_volume_and_duration(self): + """peristaltic_purge should reject both volume and duration specified.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(volume=100.0, duration=10) + + async def test_peristaltic_purge_validates_flow_rate(self): + """peristaltic_purge should validate flow rate is Low/Medium/High.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_purge(volume=1000.0, flow_rate="Invalid") + + async def test_peristaltic_purge_raises_when_device_not_initialized(self): + """peristaltic_purge should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.peristaltic_purge(volume=1000.0) + + async def test_peristaltic_purge_accepts_all_flow_rates(self): + """peristaltic_purge should accept flow rates Low, Medium, High.""" + for flow_rate in ["Low", "Medium", "High"]: + self.backend.io.set_read_buffer(b"\x06" * 100) + # Should not raise + await self.backend.peristaltic_purge(volume=500.0, flow_rate=flow_rate) + + async def test_peristaltic_purge_default_flow_rate(self): + """peristaltic_purge should use default flow rate High.""" + await self.backend.peristaltic_purge(volume=1000.0) + + # Verify command was sent + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_peristaltic_purge_raises_on_timeout(self): + """peristaltic_purge should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No ACK response + with self.assertRaises(TimeoutError): + await self.backend.peristaltic_purge(volume=1000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py new file mode 100644 index 00000000000..fd9a29eccbe --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py @@ -0,0 +1,789 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Prime operations. + +This module contains tests for prime-related step methods: +- prime (P_PRIME) +- manifold_prime (M_PRIME) +- syringe_prime (S_PRIME) +- auto_clean (M_AUTO_CLEAN) +- strip_prime (M_PRIME_STRIP) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendPeristalticPrime(unittest.IsolatedAsyncioTestCase): + """Test EL406 peristaltic prime functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) # Multiple ACKs + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_peristaltic_prime_sends_correct_command(self): + """Peristaltic prime should send correct step type and parameters.""" + initial_count = len(self.backend.io.written_data) + await self.backend.peristaltic_prime(volume=1000.0) + + # Verify a command was sent + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_peristaltic_prime_validates_flow_rate(self): + """Peristaltic prime should validate flow rate selection.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(flow_rate="Invalid") # Invalid flow rate + + async def test_peristaltic_prime_validates_volume(self): + """Peristaltic prime should validate volume range (1-3000 µL).""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(volume=-100.0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(volume=0.0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(volume=3001.0) + + async def test_peristaltic_prime_accepts_volume_boundaries(self): + """Peristaltic prime should accept volume at boundaries (1, 3000).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_prime(volume=1.0) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_prime(volume=3000.0) + + async def test_peristaltic_prime_validates_duration(self): + """Peristaltic prime should validate duration range (1-300 seconds).""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(duration=0) + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(duration=-1) + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(duration=301) + + async def test_peristaltic_prime_accepts_duration_boundaries(self): + """Peristaltic prime should accept duration at boundaries (1, 300).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_prime(duration=1) + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.peristaltic_prime(duration=300) + + async def test_peristaltic_prime_rejects_both_volume_and_duration(self): + """Peristaltic prime should reject both volume and duration specified.""" + with self.assertRaises(ValueError): + await self.backend.peristaltic_prime(volume=100.0, duration=10) + + +class TestEL406BackendSyringePrime(unittest.IsolatedAsyncioTestCase): + """Test EL406 syringe prime functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_syringe_prime_sends_command(self): + """syringe_prime should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.syringe_prime(volume=5000.0, syringe="A", flow_rate=5) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_syringe_prime_validates_volume_too_low(self): + """syringe_prime should reject volume below 80 uL.""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=79.0, syringe="A") + + async def test_syringe_prime_validates_volume_too_high(self): + """syringe_prime should reject volume above 9999 uL.""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=10000.0, syringe="A") + + async def test_syringe_prime_validates_volume_negative(self): + """syringe_prime should reject negative volume.""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=-100.0, syringe="A") + + async def test_syringe_prime_accepts_volume_boundaries(self): + """syringe_prime should accept volume at boundaries (80, 9999).""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_prime(volume=80.0, syringe="A") + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_prime(volume=9999.0, syringe="A") + + async def test_syringe_prime_validates_syringe(self): + """syringe_prime should validate syringe selection.""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="Z") + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="C") + + async def test_syringe_prime_validates_flow_rate(self): + """syringe_prime should validate flow rate (1-5).""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", flow_rate=0) + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", flow_rate=6) + + async def test_syringe_prime_validates_pump_delay(self): + """syringe_prime should validate pump delay (0-5000 ms).""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", pump_delay=-1) + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", pump_delay=5001) + + async def test_syringe_prime_validates_refills(self): + """syringe_prime should validate refills (1-255).""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", refills=0) + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", refills=256) + + async def test_syringe_prime_validates_submerge_duration(self): + """syringe_prime should validate submerge duration (0-1439 min).""" + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", submerge_duration=-1) + with self.assertRaises(ValueError): + await self.backend.syringe_prime(volume=5000.0, syringe="A", submerge_duration=1440) + + async def test_syringe_prime_raises_when_device_not_initialized(self): + """syringe_prime should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + with self.assertRaises(RuntimeError): + await backend.syringe_prime(volume=5000.0, syringe="A") + + async def test_syringe_prime_accepts_flow_rate_range(self): + """syringe_prime should accept flow rates 1-5.""" + for flow_rate in range(1, 6): + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_prime(volume=5000.0, syringe="A", flow_rate=flow_rate) + + async def test_syringe_prime_with_refills(self): + """syringe_prime should accept refills parameter.""" + initial_count = len(self.backend.io.written_data) + await self.backend.syringe_prime( + volume=5000.0, + syringe="A", + flow_rate=5, + refills=3, + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_syringe_prime_default_values(self): + """syringe_prime should use appropriate defaults.""" + await self.backend.syringe_prime(syringe="A") + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_syringe_prime_with_submerge(self): + """syringe_prime should accept submerge parameters.""" + self.backend.io.set_read_buffer(b"\x06" * 100) + await self.backend.syringe_prime( + volume=5000.0, + syringe="A", + submerge_tips=True, + submerge_duration=30, + ) + + async def test_syringe_prime_raises_on_timeout(self): + """syringe_prime should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") + with self.assertRaises(TimeoutError): + await self.backend.syringe_prime(volume=5000.0, syringe="A") + + +class TestSyringePrimeCommandEncoding(unittest.TestCase): + """Test syringe prime command binary encoding. + + Protocol format (13 bytes): + [0] plate type prefix (0x04=96-well) (step type for syringe operations) + [1] Syringe: A=0, B=1 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Refills: byte (number of prime cycles) + [6-7] Pump delay: 2 bytes, little-endian, in ms + [8] Submerge tips (0 or 1) + [9-10] Submerge duration in minutes (LE uint16) + [11] Bottle (ar-1): derived from syringe (A→0, B→2) + [12] Padding + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_syringe_prime_step_type(self): + """Syringe prime command should have prefix 0x04.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(cmd[0], 0x04) + + def test_syringe_prime_syringe_a(self): + """Syringe prime syringe A should encode as 0.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(cmd[1], 0) + + def test_syringe_prime_syringe_b(self): + """Syringe prime syringe B should encode as 1.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="B", + flow_rate=5, + ) + self.assertEqual(cmd[1], 1) + + def test_syringe_prime_lowercase_syringe(self): + """Syringe prime should accept lowercase syringe names.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="b", + flow_rate=5, + ) + self.assertEqual(cmd[1], 1) + + def test_syringe_prime_volume_encoding(self): + """Syringe prime should encode volume as little-endian 2 bytes.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(cmd[2], 0x88) + self.assertEqual(cmd[3], 0x13) + + def test_syringe_prime_volume_1000ul(self): + """Syringe prime with 1000 uL.""" + cmd = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(cmd[2], 0xE8) + self.assertEqual(cmd[3], 0x03) + + def test_syringe_prime_flow_rate(self): + """Syringe prime should encode flow rate as single byte.""" + for rate in [1, 3, 5]: + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=rate, + ) + self.assertEqual(cmd[4], rate) + + def test_syringe_prime_refills(self): + """Syringe prime should encode refills as single byte.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + refills=3, + ) + self.assertEqual(cmd[5], 3) + + def test_syringe_prime_default_refills(self): + """Syringe prime should default to 2 refills.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(cmd[5], 2) + + def test_syringe_prime_pump_delay(self): + """Syringe prime should encode pump delay as LE uint16.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + pump_delay=500, + ) + # 500 = 0x01F4 LE + self.assertEqual(cmd[6], 0xF4) + self.assertEqual(cmd[7], 0x01) + + def test_syringe_prime_command_length(self): + """Syringe prime command should have exactly 13 bytes.""" + cmd = self.backend._build_syringe_prime_command( + volume=5000.0, + syringe="A", + flow_rate=5, + ) + self.assertEqual(len(cmd), 13) + + def test_syringe_prime_full_command(self): + """Test complete syringe prime command with all parameters.""" + cmd = self.backend._build_syringe_prime_command( + volume=3000.0, + syringe="B", + flow_rate=3, + refills=4, + pump_delay=100, + submerge_tips=True, + submerge_duration=90, + ) + + self.assertEqual(len(cmd), 13) + self.assertEqual(cmd[0], 0x04) # Prefix + self.assertEqual(cmd[1], 1) # Syringe B + self.assertEqual(cmd[2], 0xB8) # Volume low (3000 = 0x0BB8) + self.assertEqual(cmd[3], 0x0B) # Volume high + self.assertEqual(cmd[4], 3) # Flow rate + self.assertEqual(cmd[5], 4) # Refills + self.assertEqual(cmd[6], 0x64) # Delay low (100 = 0x0064) + self.assertEqual(cmd[7], 0x00) # Delay high + self.assertEqual(cmd[8], 1) # Submerge tips = True + self.assertEqual(cmd[9], 0x5A) # Submerge duration low (90 min = 0x005A) + self.assertEqual(cmd[10], 0x00) # Submerge duration high + self.assertEqual(cmd[11], 2) # Bottle (B → 2) + self.assertEqual(cmd[12], 0) # Padding + + def test_syringe_prime_bottle_encoding(self): + """Test syringe prime encodes bottle from syringe selection.""" + cmd_a = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="A", + flow_rate=5, + ) + cmd_b = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="B", + flow_rate=5, + ) + self.assertEqual(cmd_a[11], 0) # A → bottle=0 + self.assertEqual(cmd_b[11], 2) # B → bottle=2 + + def test_syringe_prime_submerge_duration(self): + """Test syringe prime encodes submerge duration at bytes 9-10.""" + cmd = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="A", + flow_rate=5, + refills=2, + submerge_tips=True, + submerge_duration=90, + ) + # 90 minutes = 0x005A LE → [0x5A, 0x00] + self.assertEqual(cmd[9], 0x5A) + self.assertEqual(cmd[10], 0x00) + + def test_syringe_prime_submerge_disabled_zeroes_time(self): + """When submerge_tips=False, time bytes should be zero.""" + cmd = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="A", + flow_rate=5, + submerge_tips=False, + submerge_duration=90, + ) + self.assertEqual(cmd[8], 0) # submerge_tips=False + self.assertEqual(cmd[9], 0) # time zeroed + self.assertEqual(cmd[10], 0) + + def test_syringe_prime_submerge_max_duration(self): + """Test max submerge duration (1439 minutes = 23:59).""" + cmd = self.backend._build_syringe_prime_command( + volume=1000.0, + syringe="A", + flow_rate=5, + submerge_tips=True, + submerge_duration=1439, + ) + # 1439 = 0x059F LE → [0x9F, 0x05] + self.assertEqual(cmd[9], 0x9F) + self.assertEqual(cmd[10], 0x05) + + +class TestEL406BackendManifoldPrime(unittest.IsolatedAsyncioTestCase): + """Test EL406 manifold prime functionality. + + The manifold prime operation (eMPrime = 9) fills the wash manifold + tubing with liquid. This is used to prepare the manifold for washing. + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_manifold_prime_sends_command(self): + """manifold_prime should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_prime(volume=500.0, buffer="A", flow_rate=9) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_manifold_prime_validates_volume(self): + """manifold_prime should validate volume (5-999 mL).""" + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=0.0, buffer="A") + + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=4.0, buffer="A") + + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=1000.0, buffer="A") + + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=-100.0, buffer="A") + + async def test_manifold_prime_validates_buffer(self): + """manifold_prime should validate buffer selection.""" + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=500.0, buffer="Z") + + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=500.0, buffer="E") + + async def test_manifold_prime_validates_flow_rate(self): + """manifold_prime should validate flow rate (3-11).""" + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=500.0, buffer="A", flow_rate=2) + + with self.assertRaises(ValueError): + await self.backend.manifold_prime(volume=500.0, buffer="A", flow_rate=12) + + async def test_manifold_prime_raises_when_device_not_initialized(self): + """manifold_prime should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.manifold_prime(volume=500.0, buffer="A") + + async def test_manifold_prime_accepts_flow_rate_range(self): + """manifold_prime should accept flow rates 3-11.""" + for flow_rate in range(3, 12): + self.backend.io.set_read_buffer(b"\x06" * 100) + # Should not raise + await self.backend.manifold_prime(volume=500.0, buffer="A", flow_rate=flow_rate) + + async def test_manifold_prime_default_flow_rate(self): + """manifold_prime should use default flow rate 9.""" + await self.backend.manifold_prime(volume=500.0, buffer="A") + + # Verify command was sent (flow rate 9 is default, fastest for priming) + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_manifold_prime_raises_on_timeout(self): + """manifold_prime should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No ACK response + with self.assertRaises(TimeoutError): + await self.backend.manifold_prime(volume=500.0, buffer="A") + + +class TestManifoldPrimeCommandEncoding(unittest.TestCase): + """Test manifold prime command binary encoding. + + Protocol format for manifold prime (M_PRIME = 9): + [0] Step type: 0x09 (M_PRIME) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-9 + [5-6] Low flow volume: 2 bytes, little-endian (default 0) + [7-8] Duration: 2 bytes, little-endian (default 0) + [9-12] Padding zeros: 4 bytes + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_manifold_prime_step_type(self): + """Manifold prime command should have step type prefix 0x04.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=9, + ) + + self.assertEqual(cmd[0], 0x04) + + def test_manifold_prime_buffer_a(self): + """Manifold prime buffer A should encode as 'A' (0x41).""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=9, + ) + + # Buffer: A = 0x41 (ASCII 'A') + self.assertEqual(cmd[1], ord("A")) + + def test_manifold_prime_buffer_b(self): + """Manifold prime buffer B should encode as 'B' (0x42).""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="B", + flow_rate=9, + ) + + # Buffer: B = 0x42 (ASCII 'B') + self.assertEqual(cmd[1], ord("B")) + + def test_manifold_prime_lowercase_buffer(self): + """Manifold prime should accept lowercase buffer and encode as uppercase.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="b", + flow_rate=9, + ) + + # Should encode as uppercase 'B' + self.assertEqual(cmd[1], ord("B")) + + def test_manifold_prime_volume_encoding(self): + """Manifold prime should encode volume as little-endian 2 bytes.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=9, + ) + + # Volume: 1000 uL = 0x03E8 little-endian = [0xE8, 0x03] + self.assertEqual(cmd[2], 0xE8) + self.assertEqual(cmd[3], 0x03) + + def test_manifold_prime_volume_500ul(self): + """Manifold prime with 500 uL.""" + cmd = self.backend._build_manifold_prime_command( + volume=500.0, + buffer="A", + flow_rate=9, + ) + + # Volume: 500 uL = 0x01F4 little-endian = [0xF4, 0x01] + self.assertEqual(cmd[2], 0xF4) + self.assertEqual(cmd[3], 0x01) + + def test_manifold_prime_volume_max(self): + """Manifold prime with maximum volume (65535 uL).""" + cmd = self.backend._build_manifold_prime_command( + volume=65535.0, + buffer="A", + flow_rate=9, + ) + + # Volume: 65535 uL = 0xFFFF little-endian = [0xFF, 0xFF] + self.assertEqual(cmd[2], 0xFF) + self.assertEqual(cmd[3], 0xFF) + + def test_manifold_prime_flow_rate(self): + """Manifold prime should encode flow rate as single byte.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=7, + ) + + # Flow rate: 7 + self.assertEqual(cmd[4], 7) + + def test_manifold_prime_flow_rate_min(self): + """Manifold prime should encode minimum flow rate 1.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=1, + ) + + self.assertEqual(cmd[4], 1) + + def test_manifold_prime_flow_rate_max(self): + """Manifold prime should encode maximum flow rate 9.""" + cmd = self.backend._build_manifold_prime_command( + volume=1000.0, + buffer="A", + flow_rate=9, + ) + + self.assertEqual(cmd[4], 9) + + def test_manifold_prime_full_command(self): + """Test complete manifold prime command with all parameters.""" + cmd = self.backend._build_manifold_prime_command( + volume=2000.0, + buffer="B", + flow_rate=5, + ) + + self.assertEqual(cmd[0], 0x04) # Step type prefix + self.assertEqual(cmd[1], ord("B")) # Buffer B + self.assertEqual(cmd[2], 0xD0) # Volume low byte (2000 = 0x07D0) + self.assertEqual(cmd[3], 0x07) # Volume high byte + self.assertEqual(cmd[4], 5) # Flow rate + + +class TestEL406BackendAutoClean(unittest.IsolatedAsyncioTestCase): + """Test EL406 manifold auto-clean functionality. + + The auto-clean operation (eMAutoClean = 10) runs an automatic + cleaning cycle of the manifold. + """ + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_auto_clean_sends_command(self): + """auto_clean should send a command to the device.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_auto_clean(buffer="A") + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_auto_clean_validates_buffer(self): + """auto_clean should validate buffer selection.""" + with self.assertRaises(ValueError): + await self.backend.manifold_auto_clean(buffer="Z") + + with self.assertRaises(ValueError): + await self.backend.manifold_auto_clean(buffer="E") + + async def test_auto_clean_raises_when_device_not_initialized(self): + """auto_clean should raise RuntimeError if device not initialized.""" + backend = BioTekEL406Backend() + # Note: no setup() called + + with self.assertRaises(RuntimeError): + await backend.manifold_auto_clean(buffer="A") + + async def test_auto_clean_with_duration(self): + """auto_clean should accept duration parameter.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_auto_clean(buffer="A", duration=60.0) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_auto_clean_default_buffer(self): + """auto_clean should use default buffer A.""" + await self.backend.manifold_auto_clean() + + # Verify command was sent + self.assertGreater(len(self.backend.io.written_data), 0) + + async def test_auto_clean_raises_on_timeout(self): + """auto_clean should raise TimeoutError when device does not respond.""" + self.backend.io.set_read_buffer(b"") # No ACK response + with self.assertRaises(TimeoutError): + await self.backend.manifold_auto_clean(buffer="A") + + async def test_auto_clean_validates_negative_duration(self): + """auto_clean should raise ValueError for negative duration.""" + with self.assertRaises(ValueError): + await self.backend.manifold_auto_clean(buffer="A", duration=-10.0) + + +class TestAutoCleanCommandEncoding(unittest.TestCase): + """Test auto-clean command binary encoding. + + Protocol format for auto-clean (M_AUTO_CLEAN = 10): + [0] Step type: 0x0A (M_AUTO_CLEAN) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Duration: 2 bytes, little-endian (in seconds or other time unit) + [4-7] Padding zeros: 4 bytes + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_auto_clean_step_type(self): + """Auto-clean command should have step type prefix 0x04.""" + cmd = self.backend._build_auto_clean_command(buffer="A") + + self.assertEqual(cmd[0], 0x04) + + def test_auto_clean_buffer_a(self): + """Auto-clean buffer A should encode as 'A' (0x41).""" + cmd = self.backend._build_auto_clean_command(buffer="A") + + # Buffer: A = 0x41 (ASCII 'A') + self.assertEqual(cmd[1], ord("A")) + + def test_auto_clean_buffer_b(self): + """Auto-clean buffer B should encode as 'B' (0x42).""" + cmd = self.backend._build_auto_clean_command(buffer="B") + + # Buffer: B = 0x42 (ASCII 'B') + self.assertEqual(cmd[1], ord("B")) + + def test_auto_clean_lowercase_buffer(self): + """Auto-clean should accept lowercase buffer and encode as uppercase.""" + cmd = self.backend._build_auto_clean_command(buffer="c") + + # Should encode as uppercase 'C' + self.assertEqual(cmd[1], ord("C")) + + def test_auto_clean_duration_encoding(self): + """Auto-clean should encode duration as little-endian 2 bytes.""" + cmd = self.backend._build_auto_clean_command(buffer="A", duration=60.0) + + # Duration: 60 seconds = 0x003C little-endian = [0x3C, 0x00] + self.assertEqual(cmd[2], 0x3C) + self.assertEqual(cmd[3], 0x00) + + def test_auto_clean_duration_30_seconds(self): + """Auto-clean with 30 second duration.""" + cmd = self.backend._build_auto_clean_command(buffer="A", duration=30.0) + + # Duration: 30 seconds = 0x001E little-endian = [0x1E, 0x00] + self.assertEqual(cmd[2], 0x1E) + self.assertEqual(cmd[3], 0x00) + + def test_auto_clean_duration_zero(self): + """Auto-clean with zero duration (no additional cleaning time).""" + cmd = self.backend._build_auto_clean_command(buffer="A", duration=0.0) + + # Duration: 0 = [0x00, 0x00] + self.assertEqual(cmd[2], 0x00) + self.assertEqual(cmd[3], 0x00) + + def test_auto_clean_full_command(self): + """Test complete auto-clean command with all parameters.""" + cmd = self.backend._build_auto_clean_command( + buffer="B", + duration=90.0, + ) + + self.assertEqual(cmd[0], 0x04) # Step type prefix + self.assertEqual(cmd[1], ord("B")) # Buffer B + self.assertEqual(cmd[2], 0x5A) # Duration low byte (90 = 0x005A) + self.assertEqual(cmd[3], 0x00) # Duration high byte + + def test_auto_clean_default_duration(self): + """Auto-clean without duration should use default 1 minute.""" + cmd = self.backend._build_auto_clean_command(buffer="A") + + # Default duration: 1 = [0x01, 0x00] + self.assertEqual(cmd[2], 0x01) + self.assertEqual(cmd[3], 0x00) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py new file mode 100644 index 00000000000..3dd2bc3a455 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py @@ -0,0 +1,319 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Shake operations. + +This module contains tests for shake-related step methods: +- shake (SHAKE_SOAK) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendShake(unittest.IsolatedAsyncioTestCase): + """Test EL406 shake functionality.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_shake_sends_command(self): + """Shake should send correct command.""" + initial_count = len(self.backend.io.written_data) + await self.backend.shake(duration=10, intensity="Medium") + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_shake_validates_intensity(self): + """Shake should validate intensity value.""" + with self.assertRaises(ValueError): + await self.backend.shake(duration=10, intensity="invalid") + + async def test_shake_validates_both_zero(self): + """Shake should raise ValueError when both duration and soak_duration are 0.""" + with self.assertRaises(ValueError): + await self.backend.shake(duration=0, soak_duration=0) + + async def test_shake_validates_negative_duration(self): + """Shake should raise ValueError for negative duration.""" + with self.assertRaises(ValueError) as ctx: + await self.backend.shake(duration=-5) + + self.assertIn("duration", str(ctx.exception).lower()) + self.assertIn("-5", str(ctx.exception)) + + async def test_shake_validates_negative_soak(self): + """Shake should raise ValueError for negative soak_duration.""" + with self.assertRaises(ValueError): + await self.backend.shake(duration=10, soak_duration=-1) + + async def test_shake_validates_duration_exceeds_max(self): + """Shake should raise ValueError when duration exceeds 3599s (59:59).""" + with self.assertRaises(ValueError): + await self.backend.shake(duration=3600) + + async def test_shake_validates_soak_exceeds_max(self): + """Shake should raise ValueError when soak_duration exceeds 3599s (59:59).""" + with self.assertRaises(ValueError): + await self.backend.shake(duration=10, soak_duration=3600) + + async def test_shake_soak_only(self): + """Shake with duration=0 and soak_duration>0 should work (soak only).""" + initial_count = len(self.backend.io.written_data) + await self.backend.shake(duration=0, soak_duration=10) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestShakeCommandEncoding(unittest.TestCase): + """Test shake command binary encoding. + + Wire format for shake/soak (12 bytes with plate type prefix (0x04=96-well)): + Byte structure: + [0] plate type prefix (0x04=96-well) (step type marker, required for all step data) + [1] (move_home_first AND shake_enabled): 0x00 or 0x01 + [2-3] Shake duration in TOTAL SECONDS (16-bit little-endian) + [4] Frequency/intensity: 0x02=Slow, 0x03=Medium, 0x04=Fast + [5] Reserved: always 0x00 + [6-7] Soak duration in TOTAL SECONDS (16-bit little-endian) + [8-11] Padding/reserved: 4 bytes (0x00) + + Field mapping: + - move_home_first (bool) → byte[1]: combined with shake_enabled for byte[1] + - shake_enabled (bool) → byte[1]: combined with move_home_first for byte[1] + - shake duration (total seconds) → bytes[2-3]: 16-bit LE total seconds + - frequency → byte[4]: (Slow=0x02, Medium=0x03, Fast=0x04) + - soak duration (total seconds) → bytes[6-7]: 16-bit LE total seconds + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_shake_command_basic(self): + """Basic shake: 10 seconds, medium intensity.""" + cmd = self.backend._build_shake_command( + shake_duration=10.0, + soak_duration=0.0, + intensity="Medium", + shake_enabled=True, + ) + + # byte[0]: plate type prefix (0x04=96-well) + self.assertEqual(cmd[0], 0x04) + # byte[1]: (move_home_first AND shake_enabled) = 0x01 + self.assertEqual(cmd[1], 0x01) + # bytes[2-3]: shake duration = 10 seconds (0x0a, 0x00 little-endian) + self.assertEqual(cmd[2], 0x0A) + self.assertEqual(cmd[3], 0x00) + # byte[4]: intensity = medium = 0x03 + self.assertEqual(cmd[4], 0x03) + # byte[5]: reserved = 0x00 + self.assertEqual(cmd[5], 0x00) + # bytes[6-7]: soak duration = 0 + self.assertEqual(cmd[6], 0x00) + self.assertEqual(cmd[7], 0x00) + # bytes[8-11]: padding + self.assertEqual(cmd[8:12], bytes([0, 0, 0, 0])) + + def test_shake_command_variable_intensity(self): + """Variable intensity maps to 0x01.""" + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=0.0, + intensity="Variable", + shake_enabled=True, + ) + + self.assertEqual(cmd[4], 0x01) # variable = 0x01 + + def test_shake_command_encoding_durations(self): + """Verify encoding for various shake durations (medium intensity, move_home=True).""" + cases = [ + (30.0, "04011e000300000000000000"), # 00:30 + (60.0, "04013c000300000000000000"), # 01:00 + (300.0, "04012c010300000000000000"), # 05:00 + ] + for duration, expected_hex in cases: + with self.subTest(duration=duration): + cmd = self.backend._build_shake_command( + shake_duration=duration, + soak_duration=0.0, + intensity="Medium", + shake_enabled=True, + move_home_first=True, + ) + self.assertEqual(cmd, bytes.fromhex(expected_hex)) + + def test_shake_command_encoding_shake_disabled(self): + """Verify encoding: shake_enabled=false with move_home_first=true. + + Wire format: 04 01 00 00 03 00 00 00 00 00 00 00 + - byte[1]=0x01 because move_home_first=True + - bytes[2-3]=0x0000 because shake_enabled=False (duration=0) + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=0.0, + intensity="Medium", + shake_enabled=False, + move_home_first=True, + ) + + # byte[1] = move_home_first (0x01), bytes[2-3] = 0 (shake disabled) + expected = bytes.fromhex("040100000300000000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_move_home_false(self): + """Verify encoding: move_home_first=false. + + shake_enabled=true, move_home_first=false -> 001e000300000000000000 + Note: byte[1]=0x00 because (false AND true) = false + Wire format adds plate type prefix (0x04=96-well): 04001e000300000000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=0.0, + intensity="Medium", + shake_enabled=True, + move_home_first=False, + ) + + expected = bytes.fromhex("04001e000300000000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_soak_30s(self): + """Verify encoding: soak_duration=00:30. + + shake_duration="00:30", soak_duration="00:30" -> 011e0003001e0000000000 + Wire format adds plate type prefix (0x04=96-well): 04011e0003001e0000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=30.0, + intensity="Medium", + shake_enabled=True, + move_home_first=True, + ) + + expected = bytes.fromhex("04011e0003001e0000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_soak_60s(self): + """Verify encoding: soak_duration=01:00. + + shake_duration="00:30", soak_duration="01:00" -> 011e0003003c0000000000 + Wire format adds plate type prefix (0x04=96-well): 04011e0003003c0000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=60.0, + intensity="Medium", + shake_enabled=True, + move_home_first=True, + ) + + expected = bytes.fromhex("04011e0003003c0000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_slow_frequency(self): + """Verify encoding: frequency=Slow (3.5 Hz). + + shake_duration="00:30", frequency="Slow (3.5 Hz)" -> 011e000200000000000000 + Wire format adds plate type prefix (0x04=96-well): 04011e000200000000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=0.0, + intensity="Slow", + shake_enabled=True, + move_home_first=True, + ) + + expected = bytes.fromhex("04011e000200000000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_fast_frequency(self): + """Verify encoding: frequency=Fast (8 Hz). + + shake_duration="00:30", frequency="Fast (8 Hz)" -> 011e000400000000000000 + Wire format adds plate type prefix (0x04=96-well): 04011e000400000000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=0.0, + intensity="Fast", + shake_enabled=True, + move_home_first=True, + ) + + expected = bytes.fromhex("04011e000400000000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_complex(self): + """Verify encoding: complex combination. + + shake_duration="05:00", frequency=Slow, soak_duration="02:00" + -> 012c010200780000000000 + shake = 300s = 0x012c, slow = 0x02, soak = 120s = 0x0078 + Wire format adds plate type prefix (0x04=96-well): 04012c010200780000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=300.0, + soak_duration=120.0, + intensity="Slow", + shake_enabled=True, + move_home_first=True, + ) + + expected = bytes.fromhex("04012c010200780000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_encoding_move_home_false_with_soak(self): + """Verify encoding: move_home_first=false with soak. + + shake_enabled=true, move_home_first=false, soak_duration="01:00" + -> 001e0003003c0000000000 + Wire format adds plate type prefix (0x04=96-well): 0400 1e0003003c0000000000 + """ + cmd = self.backend._build_shake_command( + shake_duration=30.0, + soak_duration=60.0, + intensity="Medium", + shake_enabled=True, + move_home_first=False, + ) + + expected = bytes.fromhex("04001e0003003c0000000000") + self.assertEqual(cmd, expected) + + def test_shake_command_max_duration_encoding(self): + """Max duration (3599s = 59:59) encodes as 0x0E0F LE.""" + cmd = self.backend._build_shake_command( + shake_duration=3599, + soak_duration=3599, + intensity="Medium", + shake_enabled=True, + move_home_first=True, + ) + + # 3599 = 0x0E0F -> low=0x0F, high=0x0E + self.assertEqual(cmd[2], 0x0F) # shake low + self.assertEqual(cmd[3], 0x0E) # shake high + self.assertEqual(cmd[6], 0x0F) # soak low + self.assertEqual(cmd[7], 0x0E) # soak high + # Full match against expected encoding + expected = bytes.fromhex("04010f0e03000f0e00000000") + self.assertEqual(cmd, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py b/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py new file mode 100644 index 00000000000..103512c82b6 --- /dev/null +++ b/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py @@ -0,0 +1,999 @@ +# mypy: disable-error-code="union-attr,assignment,arg-type" +"""Tests for BioTek EL406 plate washer backend - Wash operations. + +This module contains tests for wash-related step methods: +- wash (MANIFOLD_WASH 0xA4, 102-byte composite command) +""" + +import unittest + +from pylabrobot.plate_washing.biotek.el406 import ( + BioTekEL406Backend, + EL406PlateType, +) +from pylabrobot.plate_washing.biotek.el406.mock_tests import MockFTDI + + +class TestEL406BackendWash(unittest.IsolatedAsyncioTestCase): + """Test EL406 wash functionality (consolidated wash method).""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_wash_sends_command(self): + """Wash should send correct command.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash(cycles=1, dispense_volume=300.0) + + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_wash_validates_cycles(self): + """Wash should validate cycle count.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(cycles=0) # Zero cycles + + async def test_wash_validates_buffer(self): + """Wash should validate buffer selection.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(buffer="Z") + + async def test_wash_validates_dispense_flow_rate(self): + """Wash should validate dispense flow rate range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(dispense_flow_rate=0) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(dispense_flow_rate=10) + + async def test_wash_validates_travel_rate(self): + """Wash should validate aspirate travel rate range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_travel_rate=0) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_travel_rate=10) + + async def test_wash_validates_pre_dispense_flow_rate(self): + """Wash should validate pre-dispense flow rate range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(pre_dispense_flow_rate=0) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(pre_dispense_flow_rate=10) + + async def test_wash_validates_dispense_x(self): + """Wash should validate dispense X offset range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(dispense_x=-200) + + async def test_wash_validates_dispense_y(self): + """Wash should validate dispense Y offset range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(dispense_y=200) + + async def test_wash_with_all_new_params(self): + """Wash should accept all new parameters.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash( + cycles=3, + buffer="B", + dispense_volume=200.0, + dispense_flow_rate=5, + dispense_x=10, + dispense_y=-5, + dispense_z=200, + aspirate_travel_rate=5, + aspirate_z=40, + pre_dispense_flow_rate=7, + aspirate_delay_ms=1000, + aspirate_x=15, + aspirate_y=-10, + final_aspirate=False, + pre_dispense_volume=100.0, + vacuum_delay_volume=50.0, + soak_duration=30, + shake_duration=10, + shake_intensity="Fast", + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_wash_validates_aspirate_delay_ms(self): + """Wash should validate aspirate delay range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_delay_ms=-1) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_delay_ms=70000) + + async def test_wash_validates_aspirate_x(self): + """Wash should validate aspirate X offset range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_x=-200) + + async def test_wash_validates_aspirate_y(self): + """Wash should validate aspirate Y offset range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(aspirate_y=200) + + async def test_wash_validates_pre_dispense_volume(self): + """Wash should validate pre-dispense volume (0 or 25-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(pre_dispense_volume=10.0) # Below 25 + # 0 should be allowed (disabled) + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash(pre_dispense_volume=0.0) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + async def test_wash_validates_vacuum_delay_volume(self): + """Wash should validate vacuum delay volume range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(vacuum_delay_volume=-1.0) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(vacuum_delay_volume=4000.0) + + async def test_wash_validates_soak_duration(self): + """Wash should validate soak duration range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(soak_duration=-1) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(soak_duration=4000) + + async def test_wash_validates_shake_duration(self): + """Wash should validate shake duration range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(shake_duration=-1) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(shake_duration=4000) + + async def test_wash_validates_shake_intensity(self): + """Wash should validate shake intensity string.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(shake_intensity="InvalidIntensity") + + +class TestWashCompositeCommandEncoding(unittest.TestCase): + """Test 102-byte MANIFOLD_WASH composite command encoding. + + This tests _build_wash_composite_command() which produces the 102-byte + payload for the MANIFOLD_WASH (0xA4) wire command. + """ + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_composite_command_length(self): + """Composite wash command should be exactly 102 bytes.""" + cmd = self.backend._build_wash_composite_command() + self.assertEqual(len(cmd), 102) + + def test_composite_command_aspirate_sections(self): + """Aspirate sections should encode travel rate and Z offsets. + + Wire Aspirate1 [29-48] = "final aspirate" with fixed Z=29. + Wire Aspirate2 [49-67] = "primary aspirate" with user params. + """ + cmd = self.backend._build_wash_composite_command(aspirate_travel_rate=5, aspirate_z=40) + # Aspirate section 1 (final aspirate, mirrors primary Z) + self.assertEqual(cmd[29], 5) # travel rate (propagated) + self.assertEqual(cmd[32], 0x28) # Z low (40) + self.assertEqual(cmd[33], 0x00) # Z high + self.assertEqual(cmd[37], 0x28) # secondary Z low (mirrors primary) + self.assertEqual(cmd[38], 0x00) # secondary Z high + + # Aspirate section 2 (primary aspirate, user params) + self.assertEqual(cmd[49], 0x00) # leading padding + self.assertEqual(cmd[50], 5) # travel rate + self.assertEqual(cmd[53], 0x28) # Z low (40) + self.assertEqual(cmd[58], 0x1D) # secondary Z low (default 29, independent) + + def test_composite_command_final_section(self): + """Final section should have shake intensity at [90].""" + cmd = self.backend._build_wash_composite_command(aspirate_travel_rate=3) + # Final section starts at [90]; [0] = shake intensity + self.assertEqual(cmd[90], 3) # shake intensity (default Medium=3) + self.assertEqual(cmd[91], 0x00) # reserved + + def test_composite_command_final_aspirate_flag(self): + """Header byte [2] should reflect final_aspirate flag.""" + cmd_on = self.backend._build_wash_composite_command(final_aspirate=True) + self.assertEqual(cmd_on[2], 0x01) + + cmd_off = self.backend._build_wash_composite_command(final_aspirate=False) + self.assertEqual(cmd_off[2], 0x00) + + def test_composite_command_pre_dispense_volume(self): + """Pre-dispense volume should appear at dispense section offsets [8-9].""" + cmd = self.backend._build_wash_composite_command(pre_dispense_volume=100.0) + # Dispense1 [7-28]: pre_disp_vol at [7+8]=15, [7+9]=16 + self.assertEqual(cmd[15], 0x64) # 100 low + self.assertEqual(cmd[16], 0x00) # 100 high + # Dispense2 [68-89]: pre_disp_vol at [68+8]=76, [68+9]=77 + self.assertEqual(cmd[76], 0x64) + self.assertEqual(cmd[77], 0x00) + + def test_composite_command_vacuum_delay_volume(self): + """Vacuum delay volume should appear at dispense section offsets [11-12].""" + cmd = self.backend._build_wash_composite_command(vacuum_delay_volume=200.0) + # Dispense1: vac_delay at [7+11]=18, [7+12]=19 + self.assertEqual(cmd[18], 0xC8) # 200 low + self.assertEqual(cmd[19], 0x00) # 200 high + # Dispense2: vac_delay at [68+11]=79, [68+12]=80 + self.assertEqual(cmd[79], 0xC8) + self.assertEqual(cmd[80], 0x00) + + def test_composite_command_aspirate_delay(self): + """Aspirate delay encoding. + + Wire Aspirate1 uses fixed defaults (delay=0 always). + Wire Aspirate2 layout: [2]=X, [3]=Y, no delay field. + aspirate_delay_ms is accepted as parameter but not currently encoded. + """ + cmd = self.backend._build_wash_composite_command(aspirate_delay_ms=1000) + # Aspirate1 always has delay=0 (fixed defaults) + self.assertEqual(cmd[30], 0x00) + self.assertEqual(cmd[31], 0x00) + + def test_composite_command_aspirate_offsets(self): + """Aspirate X/Y offsets appear in wire Aspirate2 only (primary aspirate). + + Wire Aspirate2 layout: [2]=X, [3]=Y. + Wire Aspirate1 (final aspirate) uses fixed X=0, Y=0. + """ + cmd = self.backend._build_wash_composite_command(aspirate_x=15, aspirate_y=-10) + # Aspirate1 (final aspirate): X/Y fixed at 0 + self.assertEqual(cmd[34], 0x00) + self.assertEqual(cmd[35], 0x00) + # Aspirate2 (primary aspirate): X at [49+2]=51, Y at [49+3]=52 + self.assertEqual(cmd[51], 15) + self.assertEqual(cmd[52], 0xF6) # -10 two's complement + + def test_composite_command_shake_duration(self): + """Shake duration should appear at wire [88-89] (bf section offset [1-2]).""" + cmd = self.backend._build_wash_composite_command(shake_duration=30) + # Shake section [87-101]: shake_dur at [87+1]=88, [87+2]=89 + self.assertEqual(cmd[88], 30) + self.assertEqual(cmd[89], 0x00) + + def test_composite_command_shake_intensity(self): + """Shake intensity should appear at wire [90] (bf section offset [3]).""" + cmd_fast = self.backend._build_wash_composite_command(shake_duration=10, shake_intensity="Fast") + self.assertEqual(cmd_fast[90], 0x04) + + cmd_slow = self.backend._build_wash_composite_command(shake_duration=10, shake_intensity="Slow") + self.assertEqual(cmd_slow[90], 0x02) + + cmd_var = self.backend._build_wash_composite_command( + shake_duration=10, shake_intensity="Variable" + ) + self.assertEqual(cmd_var[90], 0x01) + + def test_composite_command_shake_intensity_default_when_disabled(self): + """Shake intensity byte stays at default Medium (3) when shake_duration=0. + + The intensity field is inert when shake_duration=0.""" + cmd = self.backend._build_wash_composite_command(shake_duration=0, shake_intensity="Fast") + self.assertEqual(cmd[90], 0x03) + + def test_composite_command_soak_duration(self): + """Soak duration should appear at wire [92-93] (bf section offset [5-6]).""" + cmd = self.backend._build_wash_composite_command(soak_duration=90) + # Shake section [87-101]: soak_dur at [87+5]=92, [87+6]=93 + self.assertEqual(cmd[92], 90) + self.assertEqual(cmd[93], 0x00) + + def test_composite_command_soak_duration_large(self): + """Large soak duration should encode correctly as 16-bit LE.""" + cmd = self.backend._build_wash_composite_command(soak_duration=3599) + # 3599 = 0x0E0F -> low=0x0F, high=0x0E + # Soak is at [92-93] after the fix + self.assertEqual(cmd[92], 0x0F) + self.assertEqual(cmd[93], 0x0E) + + def test_composite_command_all_new_params(self): + """All new parameters set to non-default values should produce correct output.""" + cmd = self.backend._build_wash_composite_command( + cycles=5, + buffer="B", + dispense_volume=500.0, + dispense_flow_rate=5, + dispense_x=10, + dispense_y=-5, + dispense_z=200, + aspirate_travel_rate=5, + aspirate_z=40, + pre_dispense_flow_rate=7, + aspirate_delay_ms=2000, + aspirate_x=-20, + aspirate_y=15, + final_aspirate=False, + pre_dispense_volume=150.0, + vacuum_delay_volume=100.0, + soak_duration=60, + shake_duration=30, + shake_intensity="Slow", + ) + self.assertEqual(len(cmd), 102) + # Header + self.assertEqual(cmd[2], 0x00) # final_aspirate=False + self.assertEqual(cmd[4], 0x0F) # sector_mask low byte (default) + self.assertEqual(cmd[5], 0x00) # sector_mask high byte + self.assertEqual(cmd[6], 5) # cycles + # Dispense1 pre_dispense_volume=150 at [15-16] + self.assertEqual(cmd[15], 150 & 0xFF) + self.assertEqual(cmd[16], 0x00) + # Dispense1 pre_dispense_flow_rate=7 at [17] + self.assertEqual(cmd[17], 7) + # Dispense1 vacuum_delay=100 at [18-19] + self.assertEqual(cmd[18], 100) + self.assertEqual(cmd[19], 0x00) + # Aspirate1 (final aspirate) uses fixed defaults — delay/x/y not encoded + self.assertEqual(cmd[30], 0x00) # delay always 0 + self.assertEqual(cmd[31], 0x00) + self.assertEqual(cmd[34], 0x00) # X fixed 0 + self.assertEqual(cmd[35], 0x00) # Y fixed 0 + # Aspirate2 (primary aspirate): x=-20 at [51], y=15 at [52] + self.assertEqual(cmd[51], 0xEC) # -20 two's complement + self.assertEqual(cmd[52], 15) + # bf section at [87-101]: shake=30 at [88-89], intensity=Slow at [90], soak=60 at [92-93] + # (shake comes before soak) + self.assertEqual(cmd[88], 30) # shake duration + self.assertEqual(cmd[89], 0x00) + self.assertEqual(cmd[90], 0x02) # Slow + self.assertEqual(cmd[92], 60) # soak duration + self.assertEqual(cmd[93], 0x00) + + +class TestWashMoveHomeFirst(unittest.TestCase): + """Test move_home_first parameter in 102-byte wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_move_home_default_disabled(self): + """move_home_first defaults to False, wire [87] = 0x00.""" + cmd = self.backend._build_wash_composite_command() + self.assertEqual(cmd[87], 0x00) + + def test_move_home_enabled(self): + """move_home_first=True sets wire [87] = 0x01.""" + cmd = self.backend._build_wash_composite_command(move_home_first=True) + self.assertEqual(cmd[87], 0x01) + + def test_move_home_does_not_affect_other_bytes(self): + """Enabling move_home_first should only change byte [87].""" + cmd_off = self.backend._build_wash_composite_command(move_home_first=False) + cmd_on = self.backend._build_wash_composite_command(move_home_first=True) + # Only byte [87] should differ + diffs = [i for i in range(102) if cmd_off[i] != cmd_on[i]] + self.assertEqual(diffs, [87]) + + def test_move_home_with_shake_and_soak(self): + """move_home_first should coexist with shake/soak parameters.""" + cmd = self.backend._build_wash_composite_command( + move_home_first=True, shake_duration=15, shake_intensity="Fast", soak_duration=45 + ) + self.assertEqual(cmd[87], 0x01) # move_home + # shake at [88-89], soak at [92-93] + self.assertEqual(cmd[88], 15) # shake low + self.assertEqual(cmd[89], 0x00) # shake high + self.assertEqual(cmd[90], 0x04) # Fast intensity + self.assertEqual(cmd[92], 45) # soak low + self.assertEqual(cmd[93], 0x00) # soak high + + +class TestWashSecondaryAspirate(unittest.TestCase): + """Test secondary aspirate parameters in 102-byte wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_secondary_aspirate_disabled_default(self): + """When secondary_aspirate=False (default), Asp1 sec_z mirrors final_asp_z, + Asp2 sec_z uses secondary_z default (29).""" + cmd = self.backend._build_wash_composite_command(aspirate_z=40) + # Asp1 (final aspirate): sec_z mirrors final_asp_z (= aspirate_z) + self.assertEqual(cmd[37], 0x28) # secondary Z = 40 (mirrors final_asp_z) + self.assertEqual(cmd[38], 0x00) + self.assertEqual(cmd[39], 0x00) # secondary mode disabled + # Asp2 (primary aspirate): sec_z = default 29, mode = 0 + self.assertEqual(cmd[58], 0x1D) # secondary Z = 29 (default, independent) + self.assertEqual(cmd[59], 0x00) + self.assertEqual(cmd[55], 0x00) # secondary mode at Asp2[6] + + def test_secondary_aspirate_enabled(self): + """When secondary_aspirate=True, Asp2 gets secondary Z and mode. + + Asp1 sec_z mirrors final_asp_z (final_secondary_aspirate is off by default). + Asp2 gets user's secondary_z and secondary mode enabled. + """ + cmd = self.backend._build_wash_composite_command( + aspirate_z=40, secondary_aspirate=True, secondary_z=100 + ) + # Asp1 (final aspirate): primary Z = aspirate_z, sec_z = final_asp_z (not secondary_z), + # secondary mode stays off (final_secondary_aspirate=False) + self.assertEqual(cmd[32], 0x28) # primary Z = 40 + self.assertEqual(cmd[34], 0x00) # final secondary mode at Asp1[5] = off + self.assertEqual(cmd[37], 0x28) # secondary Z = 40 (mirrors final_asp_z, not secondary_z) + # Asp2 (primary aspirate): user params + self.assertEqual(cmd[53], 0x28) # primary Z = 40 + self.assertEqual(cmd[54], 0x00) + self.assertEqual(cmd[55], 0x01) # secondary mode at Asp2[6] = enabled + self.assertEqual(cmd[58], 0x64) # secondary Z = 100 + self.assertEqual(cmd[59], 0x00) + + +class TestWashPreDispenseFlowRateEncoding(unittest.TestCase): + """Test pre_dispense_flow_rate encoding in wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_pre_dispense_flow_rate_encoding(self): + """pre_dispense_flow_rate should encode at correct positions.""" + cmd = self.backend._build_wash_composite_command(pre_dispense_flow_rate=7) + self.assertEqual(cmd[17], 7) # Dispense1 [10] + self.assertEqual(cmd[78], 7) # Dispense2 [10] + + +class TestWashSecondaryXY(unittest.TestCase): + """Test secondary aspirate X/Y parameters in 102-byte wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_secondary_xy_default_zero(self): + """Secondary X/Y should default to 0 and not affect baseline output.""" + cmd = self.backend._build_wash_composite_command() + # Asp1 (final aspirate): always fixed 0 + self.assertEqual(cmd[40], 0x00) # Asp1[11] = secondary X (fixed) + self.assertEqual(cmd[41], 0x00) # Asp1[12] = secondary Y (fixed) + # Asp2 (primary aspirate): secondary X at [7], Y at [8] + self.assertEqual(cmd[56], 0x00) # Asp2[7] = secondary X + self.assertEqual(cmd[57], 0x00) # Asp2[8] = secondary Y + + def test_secondary_xy_encoded_when_enabled(self): + """Secondary X/Y should be encoded in wire Aspirate2 when secondary_aspirate=True.""" + cmd = self.backend._build_wash_composite_command( + secondary_aspirate=True, secondary_x=15, secondary_y=-10, secondary_z=50 + ) + # Asp1 (final aspirate): always fixed 0 + self.assertEqual(cmd[40], 0x00) + self.assertEqual(cmd[41], 0x00) + # Asp2 (primary aspirate): secondary X at [7]=56, Y at [8]=57 + self.assertEqual(cmd[56], 15) + self.assertEqual(cmd[57], 0xF6) # -10 two's complement + + def test_secondary_xy_zero_when_disabled(self): + """Secondary X/Y should be 0 when secondary_aspirate=False, even if values set.""" + cmd = self.backend._build_wash_composite_command( + secondary_aspirate=False, secondary_x=15, secondary_y=-10 + ) + self.assertEqual(cmd[40], 0x00) # Asp1 (always fixed) + self.assertEqual(cmd[41], 0x00) + self.assertEqual(cmd[56], 0x00) # Asp2 secondary X + self.assertEqual(cmd[57], 0x00) # Asp2 secondary Y + + +class TestWashBottomWash(unittest.TestCase): + """Test bottom wash parameters in 102-byte wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_bottom_wash_disabled_dispense1_mirrors_main(self): + """When bottom_wash=False, Dispense1 should mirror main dispense volume/flow.""" + cmd = self.backend._build_wash_composite_command(dispense_volume=500.0, dispense_flow_rate=5) + # Dispense1 [7-28]: vol at [8-9], flow at [10] + self.assertEqual(cmd[8], 0xF4) # 500 low + self.assertEqual(cmd[9], 0x01) # 500 high + self.assertEqual(cmd[10], 5) # flow rate + # Dispense2 [68-89]: vol at [69-70], flow at [71] + self.assertEqual(cmd[69], 0xF4) # 500 low + self.assertEqual(cmd[70], 0x01) # 500 high + self.assertEqual(cmd[71], 5) # flow rate + + def test_bottom_wash_enabled_dispense1_uses_bottom_params(self): + """When bottom_wash=True, Dispense1 should use bottom wash volume/flow.""" + cmd = self.backend._build_wash_composite_command( + dispense_volume=300.0, + dispense_flow_rate=7, + bottom_wash=True, + bottom_wash_volume=200.0, + bottom_wash_flow_rate=5, + ) + # Dispense1 [7-28]: bottom wash vol=200 at [8-9], flow=5 at [10] + self.assertEqual(cmd[8], 0xC8) # 200 low + self.assertEqual(cmd[9], 0x00) # 200 high + self.assertEqual(cmd[10], 5) # bottom wash flow rate + # Dispense2 [68-89]: main vol=300 at [69-70], flow=7 at [71] + self.assertEqual(cmd[69], 0x2C) # 300 low + self.assertEqual(cmd[70], 0x01) # 300 high + self.assertEqual(cmd[71], 7) # main flow rate + + +class TestWashBottomWashValidation(unittest.IsolatedAsyncioTestCase): + """Test bottom wash parameter validation.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_bottom_wash_validates_volume(self): + """Bottom wash should validate volume range (25-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(bottom_wash=True, bottom_wash_volume=10.0) + with self.assertRaises(ValueError): + await self.backend.manifold_wash(bottom_wash=True, bottom_wash_volume=0.0) + + async def test_bottom_wash_validates_flow_rate(self): + """Bottom wash should validate flow rate range.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash( + bottom_wash=True, bottom_wash_volume=200.0, bottom_wash_flow_rate=0 + ) + + async def test_bottom_wash_sends_command(self): + """Bottom wash should send a command successfully.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash( + bottom_wash=True, bottom_wash_volume=200.0, bottom_wash_flow_rate=5 + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestWashPreDispenseBetweenCycles(unittest.TestCase): + """Test pre-dispense between cycles parameters in 102-byte wash command.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_midcyc_disabled_dispense2_uses_main_pre_dispense(self): + """When midcyc volume=0, Dispense2 pre-dispense mirrors main pre-dispense.""" + cmd = self.backend._build_wash_composite_command( + pre_dispense_volume=100.0, pre_dispense_flow_rate=7 + ) + # Dispense1 pre-disp at [15-16], flow at [17] + self.assertEqual(cmd[15], 100) + self.assertEqual(cmd[16], 0x00) + self.assertEqual(cmd[17], 7) + # Dispense2 pre-disp at [76-77], flow at [78] + self.assertEqual(cmd[76], 100) + self.assertEqual(cmd[77], 0x00) + self.assertEqual(cmd[78], 7) + + def test_midcyc_enabled_dispense2_uses_midcyc_values(self): + """When midcyc volume>0, Dispense2 pre-dispense uses midcyc values.""" + cmd = self.backend._build_wash_composite_command( + pre_dispense_volume=100.0, + pre_dispense_flow_rate=7, + pre_dispense_between_cycles_volume=50.0, + pre_dispense_between_cycles_flow_rate=5, + ) + # Dispense1 pre-disp: main values (100, flow 7) + self.assertEqual(cmd[15], 100) + self.assertEqual(cmd[16], 0x00) + self.assertEqual(cmd[17], 7) + # Dispense2 pre-disp: midcyc values (50, flow 5) + self.assertEqual(cmd[76], 50) + self.assertEqual(cmd[77], 0x00) + self.assertEqual(cmd[78], 5) + + +class TestWashPreDispenseBetweenCyclesValidation(unittest.IsolatedAsyncioTestCase): + """Test pre-dispense between cycles validation.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_midcyc_validates_volume(self): + """Pre-dispense between cycles should validate volume (0 or 25-3000).""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(pre_dispense_between_cycles_volume=10.0) + + async def test_midcyc_validates_flow_rate(self): + """Pre-dispense between cycles should validate flow rate.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash( + pre_dispense_between_cycles_volume=50.0, pre_dispense_between_cycles_flow_rate=0 + ) + + async def test_midcyc_sends_command(self): + """Pre-dispense between cycles should send a command successfully.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash( + pre_dispense_between_cycles_volume=50.0, pre_dispense_between_cycles_flow_rate=9 + ) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestWashCaptureVectors(unittest.TestCase): + """Byte-exact match against reference captures from real EL406 hardware.""" + + def setUp(self): + self.backend = BioTekEL406Backend() + + def test_baseline(self): + """Baseline wash: 3 cycles, flow 7, Z=121, travel 3, asp Z=29.""" + expected = bytes.fromhex( + "040001000f0003412c01070000790000000900000000000000000000000300001d000000001d" + "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" + "0000090000000000000000000000030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command() + self.assertEqual(cmd, expected) + + def test_aspirate_xyz_capture(self): + """Aspirate with Z=28, X=5, Y=10.""" + expected = bytes.fromhex( + "040001000f0003412c01070000790000000900000000000000000000000300001c000000001c" + "00000000000000000000000003050a1c000000001d000000000000000000412c010700007900" + "0000090000000000000000000000030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command(aspirate_z=28, aspirate_x=5, aspirate_y=10) + self.assertEqual(cmd, expected) + + def test_secondary_aspirate_capture(self): + """Secondary aspirate enabled — mode byte at Asp2[6].""" + expected = bytes.fromhex( + "040001000f0003412c01070000790000000900000000000000000000000300001d000000001d" + "0000000000000000000000000300001d000100001d000000000000000000412c010700007900" + "0000090000000000000000000000030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command(secondary_aspirate=True) + self.assertEqual(cmd, expected) + + def test_final_secondary_aspirate_capture(self): + """Final secondary aspirate.""" + expected = bytes.fromhex( + "040001000f0002412c010700007900000009000000000000000000000003" + "00001d000100002800000000000000000000000003" + "00001d000000001d000000000000000000" + "412c0107000079000000090000000000000000000000" + "030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command( + cycles=2, buffer="A", final_secondary_aspirate=True, final_secondary_z=40 + ) + self.assertEqual(cmd, expected) + + def test_bottom_wash_capture(self): + """Bottom wash: header[1]=1, Dispense1 vol=200, flow=5.""" + expected = bytes.fromhex( + "040101000f000341c800050000790000000900000000000000000000000300001d000000001d" + "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" + "0000090000000000000000000000030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command( + bottom_wash=True, bottom_wash_volume=200.0, bottom_wash_flow_rate=5 + ) + self.assertEqual(cmd, expected) + + def test_pre_dispense_between_cycles_capture(self): + """Pre-dispense between cycles: Dispense2 pre-disp vol=50 at [76].""" + expected = bytes.fromhex( + "040001000f0003412c01070000790000000900000000000000000000000300001d000000001d" + "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" + "3200090000000000000000000000030000000000000000000000" + ) + cmd = self.backend._build_wash_composite_command( + pre_dispense_between_cycles_volume=50.0, pre_dispense_between_cycles_flow_rate=9 + ) + self.assertEqual(cmd, expected) + + def test_aspirate_delay_capture(self): + """Aspirate delay: aspirate_delay=1ms, final_aspirate_delay=2ms. + + 384-well plate (prefix=0x01). Uses 384-well backend for full byte-exact match. + Delay encoding: wire [48-49]=delay LE (spans Asp1[19] + Asp2[0]). + Secondary Z = 22 (explicitly set, independent of aspirate_z). + """ + capture_hex = ( + "010001000f0003" + "4164000700007800000009000000000000000000020003000016000000001600000000000000" + "000000010003000016000000001600000000000000000041640007000078000000090000" + "000000000000000000030000000000000000000000" + ) + expected = bytes.fromhex(capture_hex) + backend_384 = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + cmd = backend_384._build_wash_composite_command( + cycles=3, + sector_mask=0x0F, + buffer="A", + dispense_volume=100.0, + dispense_flow_rate=7, + dispense_z=120, + aspirate_travel_rate=3, + aspirate_z=22, + pre_dispense_flow_rate=9, + aspirate_delay_ms=1, + final_aspirate_delay_ms=2, + secondary_z=22, + ) + self.assertEqual(cmd, expected) + + def test_p384_sector_plate_format_capture(self): + """384-well sector wash: 2 commands with different sector masks and formats. + + 384-well plate (prefix=0x01). Uses 384-well backend with sector_mask + and wash_format for full byte-exact match. + All share: vol=100, flow=7, dispZ=120, aspZ=22, secZ=22, travelRate=3. + """ + backend_384 = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + + # Command 0: Plate format, sector_mask=0x0E (Q2+Q3+Q4), 1 cycle + cap0 = bytes.fromhex( + "010001000e00014164000700007800000009000000000000000000000003000016000000001600" + "000000000000000000000003000016000000001600000000000000000041640007000078000000" + "090000000000000000000000030000000000000000000000" + ) + cmd0 = backend_384._build_wash_composite_command( + cycles=1, + sector_mask=0x0E, + buffer="A", + dispense_volume=100.0, + dispense_flow_rate=7, + dispense_z=120, + aspirate_travel_rate=3, + aspirate_z=22, + pre_dispense_flow_rate=9, + secondary_z=22, + ) + self.assertEqual(cmd0, cap0) + + # Command 2: Sector format, sector_mask=0x0F, 1 cycle — byte [3]=0x01 + cap2 = bytes.fromhex( + "010001010f00014164000700007800000009000000000000000000000003000016000000001600" + "000000000000000000000003000016000000001600000000000000000041640007000078000000" + "090000000000000000000000030000000000000000000000" + ) + cmd2 = backend_384._build_wash_composite_command( + cycles=1, + sector_mask=0x0F, + buffer="A", + dispense_volume=100.0, + dispense_flow_rate=7, + dispense_z=120, + aspirate_travel_rate=3, + aspirate_z=22, + pre_dispense_flow_rate=9, + secondary_z=22, + wash_format="Sector", + ) + self.assertEqual(cmd2, cap2) + + +class TestWash384WellPlateSupport(unittest.TestCase): + """Test 384-well plate support: plate_type prefix, wash_format, sector_mask.""" + + def test_384_well_plate_type_byte(self): + """384-well backend should produce byte [0] = 0x01.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[0], 0x01) + + def test_96_well_plate_type_byte(self): + """96-well backend (default) should produce byte [0] = 0x04.""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[0], 0x04) + + def test_wash_format_plate_default(self): + """Default wash_format='Plate' should produce byte [3] = 0x00.""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[3], 0x00) + + def test_wash_format_sector(self): + """wash_format='Sector' should produce byte [3] = 0x01.""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command(wash_format="Sector") + self.assertEqual(cmd[3], 0x01) + + def test_cycles_at_byte6(self): + """cycles should be encoded at byte [6] (bg.a8).""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command(cycles=5) + self.assertEqual(cmd[6], 5) + + def test_cycles_default(self): + """Default cycles=3 should produce byte [6] = 0x03.""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[6], 3) + + def test_sector_mask_le_encoding(self): + """Sector mask should be encoded as 16-bit LE at bytes [4-5] (bg.a7).""" + backend = BioTekEL406Backend() + cmd = backend._build_wash_composite_command(sector_mask=0x0E) + self.assertEqual(cmd[4], 0x0E) + self.assertEqual(cmd[5], 0x00) + + def test_384_well_full_combination(self): + """384-well with Sector format and custom sector mask.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + cmd = backend._build_wash_composite_command( + wash_format="Sector", cycles=1, sector_mask=0x0E, aspirate_travel_rate=3 + ) + self.assertEqual(cmd[0], 0x01) # plate type + self.assertEqual(cmd[3], 0x01) # wash format = Sector + self.assertEqual(cmd[4], 0x0E) # sector mask low byte + self.assertEqual(cmd[5], 0x00) # sector mask high byte + self.assertEqual(cmd[6], 1) # wash cycles + self.assertEqual(cmd[29], 3) # Asp1 travel rate + self.assertEqual(cmd[50], 3) # Asp2 travel rate + self.assertEqual(len(cmd), 102) + + +class TestWash384WellValidation(unittest.IsolatedAsyncioTestCase): + """Test validation of 384-well parameters in wash() API.""" + + async def asyncSetUp(self): + self.backend = BioTekEL406Backend(timeout=0.5) + self.backend.io = MockFTDI() + await self.backend.setup() + self.backend.io.set_read_buffer(b"\x06" * 100) + + async def asyncTearDown(self): + if self.backend.io is not None: + await self.backend.stop() + + async def test_wash_format_invalid(self): + """wash() should reject invalid wash_format values.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(wash_format="Invalid") + + async def test_sectors_invalid(self): + """wash() should reject out-of-range sector/quadrant values.""" + with self.assertRaises(ValueError): + await self.backend.manifold_wash(sectors=[0]) # Must be 1-4 + with self.assertRaises(ValueError): + await self.backend.manifold_wash(sectors=[5]) # Must be 1-4 + + async def test_wash_with_384_params_sends_command(self): + """wash() should accept and send command with 384-well params.""" + initial_count = len(self.backend.io.written_data) + await self.backend.manifold_wash(wash_format="Sector", sectors=[2, 3, 4], cycles=1) + self.assertGreater(len(self.backend.io.written_data), initial_count) + + +class TestWashPlateTypeDefaults(unittest.TestCase): + """Test plate-type-aware defaults for wash parameters. + + Wash defaults are based on plate type: + - dispense_volume: 300 uL for 96-well (well_count==96), 100 uL for others + - dispense_z: plate-type-specific manifold dispense Z + - aspirate_z: plate-type-specific manifold aspirate Z + - secondary_z: same as aspirate_z default (independent of user-provided aspirate_z) + """ + + def test_96_well_defaults(self): + """96-well plate should use 96-well defaults (300uL, dispZ=121, aspZ=29).""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_96_WELL) + cmd = backend._build_wash_composite_command() + # dispense_volume=300 -> 0x012C LE at Dispense1[1-2] = wire [8-9] + self.assertEqual(cmd[8], 0x2C) + self.assertEqual(cmd[9], 0x01) + # dispense_z=121 -> 0x0079 LE at Dispense1[6-7] = wire [13-14] + self.assertEqual(cmd[13], 0x79) + self.assertEqual(cmd[14], 0x00) + # aspirate_z=29 -> 0x001D at Asp2[4-5] = wire [53-54] + self.assertEqual(cmd[53], 0x1D) + self.assertEqual(cmd[54], 0x00) + # secondary_z=29 at Asp2[9-10] = wire [58-59] + self.assertEqual(cmd[58], 0x1D) + self.assertEqual(cmd[59], 0x00) + + def test_384_well_defaults(self): + """384-well plate should use 384-well defaults (100uL, dispZ=120, aspZ=22).""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + cmd = backend._build_wash_composite_command() + # plate type prefix + self.assertEqual(cmd[0], 0x01) + # dispense_volume=100 -> 0x0064 LE at wire [8-9] + self.assertEqual(cmd[8], 0x64) + self.assertEqual(cmd[9], 0x00) + # dispense_z=120 -> 0x0078 LE at wire [13-14] + self.assertEqual(cmd[13], 0x78) + self.assertEqual(cmd[14], 0x00) + # aspirate_z=22 -> 0x0016 at wire [53-54] + self.assertEqual(cmd[53], 0x16) + self.assertEqual(cmd[54], 0x00) + # secondary_z=22 at wire [58-59] + self.assertEqual(cmd[58], 0x16) + self.assertEqual(cmd[59], 0x00) + # Dispense2 mirrors + self.assertEqual(cmd[69], 0x64) # vol low + self.assertEqual(cmd[74], 0x78) # disp_z low + + def test_384_pcr_defaults(self): + """384 PCR plate should use its specific defaults (100uL, dispZ=83, aspZ=2).""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_PCR) + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[0], 0x02) # plate type + self.assertEqual(cmd[8], 0x64) # vol=100 low + self.assertEqual(cmd[13], 0x53) # dispense_z=83 + self.assertEqual(cmd[53], 0x02) # aspirate_z=2 + self.assertEqual(cmd[58], 0x02) # secondary_z=2 + + def test_1536_well_defaults(self): + """1536-well plate: 100uL (1536 wells != 96), dispZ=94, aspZ=42.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_1536_WELL) + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[0], 0x00) # plate type + # dispense_volume=100 (1536 wells != 96) + self.assertEqual(cmd[8], 0x64) + self.assertEqual(cmd[9], 0x00) + # dispense_z=94 -> 0x005E + self.assertEqual(cmd[13], 0x5E) + self.assertEqual(cmd[14], 0x00) + # aspirate_z=42 -> 0x002A + self.assertEqual(cmd[53], 0x2A) + self.assertEqual(cmd[54], 0x00) + + def test_1536_flange_defaults(self): + """1536 flange plate: 100uL (1536 wells != 96), dispZ=93, aspZ=13.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_1536_FLANGE) + cmd = backend._build_wash_composite_command() + self.assertEqual(cmd[0], 0x0E) # plate type = 14 + # dispense_volume=100 (1536 wells != 96) + self.assertEqual(cmd[8], 0x64) + self.assertEqual(cmd[9], 0x00) + # dispense_z=93 -> 0x005D + self.assertEqual(cmd[13], 0x5D) + self.assertEqual(cmd[14], 0x00) + # aspirate_z=13 -> 0x000D + self.assertEqual(cmd[53], 0x0D) + self.assertEqual(cmd[54], 0x00) + + def test_explicit_values_override_plate_defaults(self): + """Explicit parameter values should override plate-type defaults.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_384_WELL) + cmd = backend._build_wash_composite_command( + dispense_volume=500.0, dispense_z=200, aspirate_z=50, secondary_z=30 + ) + # dispense_volume=500 overrides 100 + self.assertEqual(cmd[8], 0xF4) # 500 low + self.assertEqual(cmd[9], 0x01) # 500 high + # dispense_z=200 overrides 120 + self.assertEqual(cmd[13], 0xC8) # 200 + # aspirate_z=50 overrides 22 + self.assertEqual(cmd[53], 0x32) # 50 + # secondary_z=30 overrides 22 + self.assertEqual(cmd[58], 0x1E) # 30 + + def test_secondary_z_independent_of_aspirate_z(self): + """secondary_z default should be plate-type default, NOT user aspirate_z.""" + backend = BioTekEL406Backend(plate_type=EL406PlateType.PLATE_96_WELL) + cmd = backend._build_wash_composite_command(aspirate_z=40) + # aspirate_z=40 (user override) + self.assertEqual(cmd[53], 0x28) # aspirate_z = 40 + # secondary_z should still be 29 (plate-type default), NOT 40 + self.assertEqual(cmd[58], 0x1D) # secondary_z = 29 + + def test_all_plate_types_produce_102_bytes(self): + """Every plate type should produce exactly 102 bytes with defaults.""" + for pt in EL406PlateType: + backend = BioTekEL406Backend(plate_type=pt) + cmd = backend._build_wash_composite_command() + self.assertEqual(len(cmd), 102, f"Wrong length for {pt.name}") + self.assertEqual(cmd[0], pt.value, f"Wrong prefix for {pt.name}") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_washing/plate_washer.py b/pylabrobot/plate_washing/plate_washer.py new file mode 100644 index 00000000000..4082f49a966 --- /dev/null +++ b/pylabrobot/plate_washing/plate_washer.py @@ -0,0 +1,64 @@ +"""PlateWasher frontend class. + +This module provides the user-facing API for plate washers. +""" + +from __future__ import annotations + +from pylabrobot.machines.machine import Machine +from pylabrobot.plate_washing.backend import PlateWasherBackend +from pylabrobot.resources import Resource + + +class PlateWasher(Resource, Machine): + """Frontend class for plate washers. + + Plate washers are devices that automate the washing of microplates. + This class provides setup/stop lifecycle management, with device-specific + operations accessed directly on the backend. + + Example: + >>> from pylabrobot.plate_washing import PlateWasher + >>> from pylabrobot.plate_washing.biotek.el406 import BioTekEL406Backend + >>> washer = PlateWasher( + ... name="washer", + ... size_x=200, size_y=200, size_z=100, + ... backend=BioTekEL406Backend() + ... ) + >>> await washer.setup() + >>> await washer.backend.manifold_prime(buffer="A", volume=1000) + >>> await washer.stop() + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: PlateWasherBackend, + category: str | None = None, + model: str | None = None, + ) -> None: + """Initialize a PlateWasher. + + Args: + name: Unique name for this plate washer. + size_x: Width of the washer in millimeters. + size_y: Depth of the washer in millimeters. + size_z: Height of the washer in millimeters. + backend: Backend implementation for hardware communication. + category: Optional category string. + model: Optional model string. + """ + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + Machine.__init__(self, backend=backend) + self.backend: PlateWasherBackend = backend diff --git a/pylabrobot/plate_washing/plate_washer_tests.py b/pylabrobot/plate_washing/plate_washer_tests.py new file mode 100644 index 00000000000..f157242f061 --- /dev/null +++ b/pylabrobot/plate_washing/plate_washer_tests.py @@ -0,0 +1,72 @@ +"""Tests for PlateWasher frontend.""" + +import unittest + +from pylabrobot.plate_washing.backend import PlateWasherBackend +from pylabrobot.plate_washing.plate_washer import PlateWasher + + +class MockPlateWasherBackend(PlateWasherBackend): + """A minimal mock backend for testing the PlateWasher frontend.""" + + def __init__(self): + super().__init__() + self.setup_called = False + self.stop_called = False + + async def setup(self) -> None: + self.setup_called = True + + async def stop(self) -> None: + self.stop_called = True + + +class TestPlateWasherSetup(unittest.IsolatedAsyncioTestCase): + """Test PlateWasher setup and teardown.""" + + def setUp(self) -> None: + self.backend = MockPlateWasherBackend() + self.washer = PlateWasher( + name="test_washer", + size_x=200.0, + size_y=200.0, + size_z=100.0, + backend=self.backend, + ) + + async def test_setup_calls_backend_setup(self): + """Setup should call backend.setup().""" + await self.washer.setup() + self.assertTrue(self.backend.setup_called) + + async def test_setup_finished_after_setup(self): + """setup_finished should be True after setup().""" + self.assertFalse(self.washer.setup_finished) + await self.washer.setup() + self.assertTrue(self.washer.setup_finished) + + async def test_stop_calls_backend_stop(self): + """Stop should call backend.stop().""" + await self.washer.setup() + await self.washer.stop() + self.assertTrue(self.backend.stop_called) + + async def test_context_manager(self): + """PlateWasher should work as async context manager.""" + async with self.washer: + self.assertTrue(self.backend.setup_called) + self.assertTrue(self.backend.stop_called) + + +class TestPlateWasherSerialization(unittest.TestCase): + """Test PlateWasher serialization.""" + + def test_backend_serialization(self): + """Backend should serialize correctly.""" + backend = MockPlateWasherBackend() + serialized = backend.serialize() + self.assertEqual(serialized["type"], "MockPlateWasherBackend") + + +if __name__ == "__main__": + unittest.main()