From 7bcbdbd88ba89b63cf0adc5591b515228f7a4caa Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Apr 2026 18:48:43 -0700 Subject: [PATCH 01/11] Port Hamilton Vantage to new Device/Driver/Backend architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decomposes the legacy monolithic VantageBackend (~5,334 lines) into the same layered architecture established by the STAR port: - VantageDriver(HamiltonLiquidHandler) — USB I/O, firmware protocol, setup - VantagePIPBackend(PIPBackend) — independent channel operations - VantageHead96Backend(Head96Backend) — 96-head operations - IPGBackend(OrientableGripperArmBackend) — plate gripper - VantageXArm, VantageLoadingCover — auxiliary subsystems - VantageChatterboxDriver — mock driver for testing - Vantage(Device) — user-facing device wiring capabilities All firmware commands verified parameter-by-parameter against the legacy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/vantage/__init__.py | 3 + .../liquid_handlers/vantage/chatterbox.py | 93 ++ .../liquid_handlers/vantage/driver.py | 340 ++++++++ .../liquid_handlers/vantage/errors.py | 282 ++++++ .../liquid_handlers/vantage/fw_parsing.py | 63 ++ .../liquid_handlers/vantage/head96_backend.py | 649 ++++++++++++++ .../hamilton/liquid_handlers/vantage/ipg.py | 279 ++++++ .../liquid_handlers/vantage/loading_cover.py | 56 ++ .../liquid_handlers/vantage/pip_backend.py | 822 ++++++++++++++++++ .../liquid_handlers/vantage/tests/__init__.py | 0 .../vantage/tests/test_errors.py | 75 ++ .../vantage/tests/test_fw_parsing.py | 45 + .../liquid_handlers/vantage/vantage.py | 61 ++ .../hamilton/liquid_handlers/vantage/x_arm.py | 125 +++ 14 files changed, 2893 insertions(+) create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/driver.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/errors.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/ipg.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/tests/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/vantage.py create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py new file mode 100644 index 00000000000..c2a906a35dd --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py @@ -0,0 +1,3 @@ +from .chatterbox import VantageChatterboxDriver +from .driver import VantageDriver +from .vantage import Vantage diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py new file mode 100644 index 00000000000..c0b6d30cbda --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py @@ -0,0 +1,93 @@ +"""VantageChatterboxDriver: mock driver that prints commands instead of sending to hardware.""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from .driver import VantageDriver + +logger = logging.getLogger("pylabrobot") + + +class VantageChatterboxDriver(VantageDriver): + """A VantageDriver that prints firmware commands instead of communicating with hardware. + + Useful for testing, debugging, and development without a physical Vantage. + """ + + def __init__(self): + super().__init__() + + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + # Skip USB and hardware discovery entirely. + # Import backends here to avoid circular imports. + from .head96_backend import VantageHead96Backend + from .ipg import IPGBackend + from .loading_cover import VantageLoadingCover + from .pip_backend import VantagePIPBackend + from .x_arm import VantageXArm + + self.id_ = 0 + self._num_channels = 8 + + self.pip = VantagePIPBackend(self) + self.head96 = VantageHead96Backend(self) if not skip_core96 else None + self.ipg = IPGBackend(driver=self) if not skip_ipg else None + if self.ipg is not None: + self.ipg._parked = True + self.x_arm = VantageXArm(driver=self) + self.loading_cover = VantageLoadingCover(driver=self) + + # Initialize subsystems. + for sub in self._subsystems: + await sub._on_setup() + + async def stop(self): + # Stop subsystems (no-ops for chatterbox, but follows the pattern). + for sub in reversed(self._subsystems): + await sub._on_stop() + # Clear state (skip super().stop() since there is no USB to close). + self._num_channels = None + self._tth2tti.clear() + self.head96 = None + self.ipg = None + self.x_arm = None + self.loading_cover = None + + async def send_command( + self, + module: str, + command: str, + auto_id: bool = True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + fmt: Optional[Any] = None, + **kwargs, + ): + cmd, _ = self._assemble_command( + module=module, + command=command, + tip_pattern=tip_pattern, + auto_id=auto_id, + **kwargs, + ) + logger.info("Chatterbox: %s", cmd) + return None + + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + logger.info("Chatterbox raw: %s", command) + return None diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py new file mode 100644 index 00000000000..0195b5dcef6 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py @@ -0,0 +1,340 @@ +"""VantageDriver: inherits HamiltonLiquidHandler, adds Vantage-specific config and error handling.""" + +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Union + +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler +from pylabrobot.resources.hamilton import TipPickupMethod, TipSize + +from .errors import vantage_response_string_to_error +from .fw_parsing import parse_vantage_fw_string + +if TYPE_CHECKING: + from .ipg import IPGBackend + from .loading_cover import VantageLoadingCover + from .x_arm import VantageXArm + + +class VantageDriver(HamiltonLiquidHandler): + """Driver for Hamilton Vantage liquid handlers. + + Inherits USB I/O, command assembly, and background reading from HamiltonLiquidHandler. + Adds Vantage-specific firmware parsing, error handling, and subsystem management. + """ + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 60, + write_timeout: int = 30, + ): + super().__init__( + id_product=0x8003, + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + self._num_channels: Optional[int] = None + self._traversal_height: float = 245.0 + + # Populated during setup(). + self.pip: Optional[PIPBackend] = None # set in setup() + self.head96: Optional[Head96Backend] = None # set in setup() if installed + self.ipg: Optional["IPGBackend"] = None # set in setup() if installed + self.x_arm: Optional["VantageXArm"] = None # set in setup() + self.loading_cover: Optional["VantageLoadingCover"] = None # set in setup() + + # -- HamiltonLiquidHandler abstract methods -------------------------------- + + @property + def module_id_length(self) -> int: + return 4 + + @property + def num_channels(self) -> int: + if self._num_channels is None: + raise RuntimeError("Driver not set up - call setup() first.") + return self._num_channels + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + parsed = parse_vantage_fw_string(resp, {"id": "int"}) + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + def check_fw_string_error(self, resp: str) -> None: + if "er" in resp and "er0" not in resp: + raise vantage_response_string_to_error(resp) + + def _parse_response(self, resp: str, fmt: Any) -> dict: + return parse_vantage_fw_string(resp, fmt) + + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ) -> None: + if not 0 <= tip_type_table_index <= 99: + raise ValueError("tip_type_table_index must be between 0 and 99") + if not 1 <= tip_length <= 1999: + raise ValueError("tip_length must be between 1 and 1999") + if not 1 <= maximum_tip_volume <= 56000: + raise ValueError("maximum_tip_volume must be between 1 and 56000") + + await self.send_command( + module="A1AM", + command="TT", + ti=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{tip_length:04}", + tv=f"{maximum_tip_volume:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + # -- traversal height ------------------------------------------------------ + + def set_minimum_traversal_height(self, traversal_height: float) -> None: + """Set the minimum traversal height (mm). Used as default for z-safety parameters.""" + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + self._traversal_height = traversal_height + + @property + def traversal_height(self) -> float: + return self._traversal_height + + # -- lifecycle ------------------------------------------------------------- + + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + await super().setup() + self.id_ = 0 + + # Import here to avoid circular imports. + from .head96_backend import VantageHead96Backend + from .ipg import IPGBackend + from .loading_cover import VantageLoadingCover + from .pip_backend import VantagePIPBackend + from .x_arm import VantageXArm + + # Discover channel count. + tip_presences = await self.query_tip_presence() + self._num_channels = len(tip_presences) + + # Arm pre-initialization. + arm_initialized = await self.arm_request_instrument_initialization_status() + if not arm_initialized: + await self.arm_pre_initialize() + + # Create backends. + self.pip = VantagePIPBackend(self) + self.x_arm = VantageXArm(driver=self) + self.loading_cover = VantageLoadingCover(driver=self) + + # Initialize PIP channels. + pip_channels_initialized = await self.pip_request_initialization_status() + if not pip_channels_initialized or any(tip_presences): + await self.pip_initialize( + x_position=[7095] * self.num_channels, + y_position=[3891, 3623, 3355, 3087, 2819, 2551, 2283, 2016], + begin_z_deposit_position=[int(self._traversal_height * 10)] * self.num_channels, + end_z_deposit_position=[1235] * self.num_channels, + minimal_height_at_command_end=[int(self._traversal_height * 10)] * self.num_channels, + tip_pattern=[True] * self.num_channels, + tip_type=[1] * self.num_channels, + ts=70, + ) + + # Loading cover. + if not skip_loading_cover: + loading_cover_initialized = await self.loading_cover.request_initialization_status() + if not loading_cover_initialized: + await self.loading_cover.initialize() + + # Core 96 head. + core96_initialized = await self.core96_request_initialization_status() + if not core96_initialized and not skip_core96: + self.head96 = VantageHead96Backend(self) + await self.core96_initialize( + x_position=7347, + y_position=2684, + minimal_traverse_height_at_begin_of_command=int(self._traversal_height * 10), + minimal_height_at_command_end=int(self._traversal_height * 10), + end_z_deposit_position=2420, + ) + else: + # Even if already initialized, create the backend. + self.head96 = VantageHead96Backend(self) if not skip_core96 else None + + # IPG. + if not skip_ipg: + self.ipg = IPGBackend(driver=self) + ipg_initialized = await self.ipg.request_initialization_status() + if not ipg_initialized: + await self.ipg.initialize() + if not await self.ipg.get_parking_status(): + await self.ipg.park() + else: + self.ipg = None + + # Initialize subsystems. + for sub in self._subsystems: + await sub._on_setup() + + @property + def _subsystems(self) -> List[Any]: + """All active subsystems, for lifecycle management.""" + subs: List[Any] = [] + if self.pip is not None: + subs.append(self.pip) + if self.head96 is not None: + subs.append(self.head96) + if self.ipg is not None: + subs.append(self.ipg) + if self.x_arm is not None: + subs.append(self.x_arm) + if self.loading_cover is not None: + subs.append(self.loading_cover) + return subs + + async def stop(self): + # Stop subsystems first (they may need to send firmware commands). + for sub in reversed(self._subsystems): + await sub._on_stop() + await super().stop() + self._num_channels = None + self.head96 = None + self.ipg = None + self.x_arm = None + self.loading_cover = None + + # -- arm commands (A1AM) --------------------------------------------------- + + async def arm_request_instrument_initialization_status(self) -> bool: + """Check if the arm module is initialized (A1AM:QW).""" + resp = await self.send_command(module="A1AM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def arm_pre_initialize(self) -> None: + """Pre-initialize the arm module (A1AM:MI).""" + await self.send_command(module="A1AM", command="MI") + + # -- pip module commands (A1PM) used during setup -------------------------- + + async def pip_request_initialization_status(self) -> bool: + """Check if PIP channels are initialized (A1PM:QW).""" + resp = await self.send_command(module="A1PM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def pip_initialize( + self, + x_position: List[int], + y_position: List[int], + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + tip_type: Optional[List[int]] = None, + ts: int = 0, + ) -> None: + """Initialize PIP channels (A1PM:DI).""" + + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + if tip_type is None: + tip_type = [4] * self.num_channels + + await self.send_command( + module="A1PM", + command="DI", + xp=x_position, + yp=y_position, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + te=minimal_height_at_command_end, + tm=tip_pattern, + tt=tip_type, + ts=ts, + ) + + async def query_tip_presence(self) -> List[bool]: + """Query tip presence on all channels (A1PM:QA).""" + resp = await self.send_command(module="A1PM", command="QA", fmt={"rt": "[int]"}) + presences_int: List[int] = resp["rt"] + return [bool(p) for p in presences_int] + + # -- core 96 commands used during setup (A1HM) ----------------------------- + + async def core96_request_initialization_status(self) -> bool: + """Check if Core96 head is initialized (A1HM:QW).""" + resp = await self.send_command(module="A1HM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def core96_initialize( + self, + x_position: int = 7347, + y_position: int = 2684, + z_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 2450, + minimal_height_at_command_end: int = 2450, + end_z_deposit_position: int = 2420, + tip_type: int = 4, + ) -> None: + """Initialize Core 96 head (A1HM:DI).""" + await self.send_command( + module="A1HM", + command="DI", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + tz=end_z_deposit_position, + tt=tip_type, + ) + + # -- LED (C0AM) ------------------------------------------------------------ + + async def set_led_color( + self, + mode: Union[Literal["on"], Literal["off"], Literal["blink"]], + intensity: int, + white: int, + red: int, + green: int, + blue: int, + uv: int, + blink_interval: Optional[int] = None, + ) -> None: + """Set the instrument LED color (C0AM:LI).""" + if blink_interval is not None and mode != "blink": + raise ValueError("blink_interval is only used when mode is 'blink'.") + + await self.send_command( + module="C0AM", + command="LI", + li={"on": 1, "off": 0, "blink": 2}[mode], + os=intensity, + ok=blink_interval or 750, + ol=f"{white} {red} {green} {blue} {uv}", + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/errors.py b/pylabrobot/hamilton/liquid_handlers/vantage/errors.py new file mode 100644 index 00000000000..9100195851d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/errors.py @@ -0,0 +1,282 @@ +"""Vantage-specific firmware error classes and error parsing.""" + +import re +from typing import Dict, Optional + +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.resources.errors import HasTipError, NoTipError, TooLittleLiquidError + +from .fw_parsing import parse_vantage_fw_string + +# --------------------------------------------------------------------------- +# Error dictionaries (per-module error code -> human-readable message) +# --------------------------------------------------------------------------- + +core96_errors: Dict[int, str] = { + 0: "No error", + 21: "No communication to digital potentiometer", + 25: "Wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "Wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "Dispensing drive initialization failed", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Dispensing drive position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position out of permitted area", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position out of permitted area", + 65: "Squeezer drive initialization failed", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error", + 68: "Squeezer drive position out of permitted area", + 70: "No liquid level found", + 71: "Not enough liquid present", + 75: "No tip picked up", + 76: "Tip already picked up", + 81: "Clot detected with LLD sensor", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve name not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Not allowed limit curve index", + 96: "Limit curve already stored", +} + +pip_errors: Dict[int, str] = { + 22: "Drive controller message error", + 23: "EC drive controller setup not executed", + 25: "wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 38: "Movement interrupted by partner channel", + 39: "Angle alignment offset error", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "D drive initialization failed", + 51: "D drive not initialized", + 52: "D drive movement error", + 53: "Maximum volume in tip reached", + 54: "D drive position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position out of permitted area", + 59: "Divergance Y motion controller to linear encoder to height", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position out of permitted area", + 64: "Limit stop not found", + 65: "S drive initialization failed", + 66: "S drive not initialized", + 67: "S drive movement error", + 68: "S drive position out of permitted area", + 69: "Init. position adjustment error", + 70: "No liquid level found", + 71: "Not enough liquid present", + 74: "Liquid at a not allowed position detected", + 75: "No tip picked up", + 76: "Tip already picked up", + 77: "Tip not discarded", + 78: "Wrong tip detected", + 79: "Tip not correct squeezed", + 80: "Liquid not correctly aspirated", + 81: "Clot detected", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 85: "Jet dispense pressure not reached", + 86: "ADC algorithm error", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve name not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Not allowed limit curve index", + 96: "Limit curve already stored", +} + +ipg_errors: Dict[int, str] = { + 0: "No error", + 22: "Drive controller message error", + 23: "EC drive controller setup not executed", + 25: "Wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "Wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 39: "Angle alignment offset error", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "Y Drive initialization failed", + 51: "Y Drive not initialized", + 52: "Y Drive movement error", + 53: "Y Drive position out of permitted area", + 54: "Diff. motion controller and lin. encoder counter too high", + 55: "Z Drive initialization failed", + 56: "Z Drive not initialized", + 57: "Z Drive movement error", + 58: "Z Drive position out of permitted area", + 59: "Z Drive limit stop not found", + 60: "Rotation Drive initialization failed", + 61: "Rotation Drive not initialized", + 62: "Rotation Drive movement error", + 63: "Rotation Drive position out of permitted area", + 65: "Wrist Twist Drive initialization failed", + 66: "Wrist Twist Drive not initialized", + 67: "Wrist Twist Drive movement error", + 68: "Wrist Twist Drive position out of permitted area", + 70: "Gripper Drive initialization failed", + 71: "Gripper Drive not initialized", + 72: "Gripper Drive movement error", + 73: "Gripper Drive position out of permitted area", + 80: "Plate not found", + 81: "Plate is still held", + 82: "No plate is held", +} + + +# --------------------------------------------------------------------------- +# Module ID -> name mapping +# --------------------------------------------------------------------------- + +VANTAGE_MODULE_NAMES: Dict[str, str] = { + "I1AM": "Cover", + "C0AM": "Master", + "A1PM": "Pip", + "A1HM": "Core 96", + "A1RM": "IPG", + "A1AM": "Arm", + "A1XM": "X-arm", +} + + +# --------------------------------------------------------------------------- +# Error classes +# --------------------------------------------------------------------------- + + +class VantageFirmwareError(Exception): + """Error raised when the Vantage firmware returns an error response.""" + + def __init__(self, errors: Dict[str, str], raw_response: str): + self.errors = errors + self.raw_response = raw_response + + def __str__(self) -> str: + return f"VantageFirmwareError(errors={self.errors}, raw_response={self.raw_response})" + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, VantageFirmwareError) + and self.errors == other.errors + and self.raw_response == other.raw_response + ) + + +# --------------------------------------------------------------------------- +# Parsing firmware error responses +# --------------------------------------------------------------------------- + + +def vantage_response_string_to_error(string: str) -> VantageFirmwareError: + """Convert a Vantage firmware response string to a VantageFirmwareError. + + Assumes that the response is an error response. + """ + + try: + error_format = r"[A-Z0-9]{2}[0-9]{2}" + error_string = parse_vantage_fw_string(string, {"es": "str"})["es"] + error_codes = re.findall(error_format, error_string) + errors: Dict[str, str] = {} + num_channels = 16 + for error in error_codes: + module, error_code = error[:2], error[2:] + error_code_int = int(error_code) + for channel in range(1, num_channels + 1): + if module == f"P{channel}": + errors[f"Pipetting channel {channel}"] = pip_errors.get(error_code_int, "Unknown error") + elif module in ("H0", "HM"): + errors["Core 96"] = core96_errors.get(error_code_int, "Unknown error") + elif module == "RM": + errors["IPG"] = ipg_errors.get(error_code_int, "Unknown error") + elif module == "AM": + errors["Cover"] = "Unknown error" + except ValueError: + module_id = string[:4] + module_name = VANTAGE_MODULE_NAMES.get(module_id, "Unknown module") + error_string = parse_vantage_fw_string(string, {"et": "str"})["et"] + errors = {module_name: error_string} + + return VantageFirmwareError(errors, string) + + +# --------------------------------------------------------------------------- +# Conversion to standard PLR errors +# --------------------------------------------------------------------------- + + +def convert_vantage_firmware_error_to_plr_error( + error: VantageFirmwareError, +) -> Optional[Exception]: + """Convert a VantageFirmwareError to a standard PLR error if possible. + + Returns the converted error, or None if no conversion is applicable. + """ + + # If all errors are pipetting channel errors, return a ChannelizedError. + if all(key.startswith("Pipetting channel ") for key in error.errors): + channel_errors: Dict[int, Exception] = {} + for channel_name, message in error.errors.items(): + channel_idx = int(channel_name.split(" ")[-1]) - 1 # 1-indexed -> 0-indexed + + if message == pip_errors.get(76): # "Tip already picked up" + channel_errors[channel_idx] = HasTipError() + elif message == pip_errors.get(75): # "No tip picked up" + channel_errors[channel_idx] = NoTipError(message) + elif message in (pip_errors.get(70), pip_errors.get(71)): + channel_errors[channel_idx] = TooLittleLiquidError(message) + else: + channel_errors[channel_idx] = Exception(message) + + return ChannelizedError(errors=channel_errors, raw_response=error.raw_response) + + return None diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py b/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py new file mode 100644 index 00000000000..80c4e058470 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py @@ -0,0 +1,63 @@ +"""Vantage-specific firmware response parsing.""" + +import re +from typing import Dict, Optional + + +def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dict: + """Parse a Vantage firmware string into a dict. + + The identifier parameter (id) is added automatically. + + ``fmt`` is a dict that specifies the format of the string. The keys are the parameter names and + the values are the types. The following types are supported: + + - ``"int"``: a single integer + - ``"str"``: a string + - ``"[int]"``: a list of integers + - ``"hex"``: a hexadecimal number + + Example: + >>> parse_vantage_fw_string("id0xs30 -100 +1 1000", {"id": "int", "x": "[int]"}) + {"id": 0, "x": [30, -100, 1, 1000]} + + >>> parse_vantage_fw_string('es"error string"', {"es": "str"}) + {"es": "error string"} + """ + + parsed: dict = {} + + if fmt is None: + fmt = {} + + if not isinstance(fmt, dict): + raise TypeError(f"invalid fmt for fmt: expected dict, got {type(fmt)}") + + if "id" not in fmt: + fmt["id"] = "int" + + for key, data_type in fmt.items(): + if data_type == "int": + matches = re.findall(rf"{key}([-+]?\d+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = int(matches[0]) + elif data_type == "str": + matches = re.findall(rf"{key}\"(.*)\"", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = matches[0] + elif data_type == "[int]": + matches = re.findall(rf"{key}((?:[-+]?[\d ]+)+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = [int(x) for x in matches[0].split()] + elif data_type == "hex": + matches = re.findall(rf"{key}([0-9a-fA-F]+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = int(matches[0], 16) + else: + raise ValueError(f"Unknown data type {data_type}") + + return parsed diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py new file mode 100644 index 00000000000..0652a2c4122 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -0,0 +1,649 @@ +"""Vantage Head96 backend: translates Head96 operations into Vantage firmware commands.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) +from pylabrobot.hamilton.lh.vantage.liquid_classes import get_vantage_liquid_class +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.resources import Coordinate, Plate, TipRack +from pylabrobot.resources.hamilton import HamiltonTip +from pylabrobot.resources.liquid import Liquid + +from .errors import VantageFirmwareError, convert_vantage_firmware_error_to_plr_error +from .pip_backend import _get_dispense_mode + +if TYPE_CHECKING: + from .driver import VantageDriver + +logger = logging.getLogger("pylabrobot") + + +def _channel_pattern_to_hex(pattern: List[bool]) -> str: + """Convert a list of 96 booleans to the hex string expected by firmware.""" + if len(pattern) != 96: + raise ValueError("channel_pattern must be a list of 96 boolean values") + channel_pattern_bin_str = reversed(["1" if x else "0" for x in pattern]) + return hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + +class VantageHead96Backend(Head96Backend): + """Translates Head96 operations into Vantage firmware commands via the driver.""" + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- BackendParams --------------------------------------------------------- + + @dataclass + class PickUpTipsParams(BackendParams): + tip_handling_method: int = 0 + z_deposit_position: float = 216.4 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + + @dataclass + class DropTipsParams(BackendParams): + z_deposit_position: float = 216.4 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + + @dataclass + class AspirateParams(BackendParams): + jet: bool = False + blow_out: bool = False + hlc: Optional[HamiltonLiquidClass] = None + type_of_aspiration: int = 0 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5 + tube_2nd_section_height_measured_from_zm: float = 0 + tube_2nd_section_ratio: float = 0 + immersion_depth: float = 0 + surface_following_distance: float = 0 + transport_air_volume: Optional[float] = None + blow_out_air_volume: Optional[float] = None + pre_wetting_volume: float = 0 + lld_mode: int = 0 + lld_sensitivity: int = 4 + swap_speed: Optional[float] = None + settling_time: Optional[float] = None + limit_curve_index: int = 0 + tadm_channel_pattern: Optional[List[bool]] = None + tadm_algorithm_on_off: int = 0 + recording_mode: int = 0 + disable_volume_correction: bool = False + + @dataclass + class DispenseParams(BackendParams): + jet: bool = False + blow_out: bool = False + empty: bool = False + hlc: Optional[HamiltonLiquidClass] = None + type_of_dispensing_mode: Optional[int] = None + tube_2nd_section_height_measured_from_zm: float = 0 + tube_2nd_section_ratio: float = 0 + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5.0 + immersion_depth: float = 0 + surface_following_distance: float = 2.9 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + cut_off_speed: float = 250.0 + stop_back_volume: float = 0 + transport_air_volume: Optional[float] = None + blow_out_air_volume: Optional[float] = None + lld_mode: int = 0 + lld_sensitivity: int = 4 + side_touch_off_distance: float = 0 + swap_speed: Optional[float] = None + settling_time: Optional[float] = None + limit_curve_index: int = 0 + tadm_channel_pattern: Optional[List[bool]] = None + tadm_algorithm_on_off: int = 0 + recording_mode: int = 0 + disable_volume_correction: bool = False + + # -- Head96Backend interface ----------------------------------------------- + + async def pick_up_tips96( + self, + pickup: PickupTipRack, + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantageHead96Backend.PickUpTipsParams): + backend_params = VantageHead96Backend.PickUpTipsParams() + + tip_spot_a1 = pickup.resource.get_item("A1") + prototypical_tip = None + for tip_spot in pickup.resource.get_all_items(): + if tip_spot.has_tip(): + prototypical_tip = tip_spot.get_tip() + break + if prototypical_tip is None: + raise ValueError("No tips found in the tip rack.") + assert isinstance(prototypical_tip, HamiltonTip), "Tip type must be HamiltonTip." + + ttti = await self.driver.request_or_assign_tip_type_index(prototypical_tip) + position = tip_spot_a1.get_absolute_location(x="c", y="c", z="b") + pickup.offset + th = self.driver.traversal_height + + try: + await self._core96_tip_pick_up( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + tip_type=ttti, + tip_handling_method=backend_params.tip_handling_method, + z_deposit_position=round((backend_params.z_deposit_position + pickup.offset.z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimal_height_at_command_end or th) * 10 + ), + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + async def drop_tips96( + self, + drop: DropTipRack, + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantageHead96Backend.DropTipsParams): + backend_params = VantageHead96Backend.DropTipsParams() + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item("A1") + position = tip_spot_a1.get_absolute_location(x="c", y="c", z="b") + drop.offset + else: + raise NotImplementedError( + f"Only TipRacks are supported for dropping tips on Vantage, got {drop.resource}" + ) + + th = self.driver.traversal_height + + try: + await self._core96_tip_discard( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + z_deposit_position=round((backend_params.z_deposit_position + drop.offset.z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimal_height_at_command_end or th) * 10 + ), + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantageHead96Backend.AspirateParams): + backend_params = VantageHead96Backend.AspirateParams() + + # Resolve position and liquid surface. + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert isinstance(plate, Plate) + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = plate.get_well("H12") + elif rot.z % 360 == 0: + ref_well = plate.get_well("A1") + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + position = ( + ref_well.get_absolute_location(x="c", y="c", z="b") + + aspiration.offset + + Coordinate(z=ref_well.material_z_thickness) + ) + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 1.7 + else: + x_width = (12 - 1) * 9 + y_width = (8 - 1) * 9 + x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 + y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + aspiration.container.get_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + well_bottoms = position.z + lld_search_height = well_bottoms + aspiration.container.get_absolute_size_z() + 1.7 + + liquid_height = position.z + (aspiration.liquid_height or 0) + + tip = next(t for t in aspiration.tips if t is not None) + hlc = backend_params.hlc + if hlc is None: + hlc = get_vantage_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=backend_params.jet, + blow_out=backend_params.blow_out, + ) + + if backend_params.disable_volume_correction or hlc is None: + volume = aspiration.volume + else: + volume = hlc.compute_corrected_volume(aspiration.volume) + + transport_air_volume = backend_params.transport_air_volume or ( + hlc.aspiration_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = backend_params.blow_out_air_volume or ( + hlc.aspiration_blow_out_volume if hlc is not None else 0 + ) + flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) + swap_speed = backend_params.swap_speed or ( + hlc.aspiration_swap_speed if hlc is not None else 100 + ) + settling_time = backend_params.settling_time or ( + hlc.aspiration_settling_time if hlc is not None else 5 + ) + + th = self.driver.traversal_height + + try: + await self._core96_aspiration_of_liquid( + type_of_aspiration=backend_params.type_of_aspiration, + x_position=round(position.x * 10), + y_position=round(position.y * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimal_height_at_command_end or th) * 10 + ), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), + pull_out_distance_to_take_transport_air_in_function_without_lld=round( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + ), + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round( + backend_params.tube_2nd_section_height_measured_from_zm * 10 + ), + tube_2nd_section_ratio=round(backend_params.tube_2nd_section_ratio * 10), + immersion_depth=round(backend_params.immersion_depth * 10), + surface_following_distance=round(backend_params.surface_following_distance * 10), + aspiration_volume=round(volume * 100), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 100), + pre_wetting_volume=round(backend_params.pre_wetting_volume * 100), + lld_mode=backend_params.lld_mode, + lld_sensitivity=backend_params.lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(aspiration.mix.volume * 100) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, + mix_position_in_z_direction_from_liquid_surface=0, + surface_following_distance_during_mixing=0, + mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 20, + limit_curve_index=backend_params.limit_curve_index, + tadm_channel_pattern=backend_params.tadm_channel_pattern, + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, + recording_mode=backend_params.recording_mode, + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantageHead96Backend.DispenseParams): + backend_params = VantageHead96Backend.DispenseParams() + + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert isinstance(plate, Plate) + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = plate.get_well("H12") + elif rot.z % 360 == 0: + ref_well = plate.get_well("A1") + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + position = ( + ref_well.get_absolute_location(x="c", y="c", z="b") + + dispense.offset + + Coordinate(z=ref_well.material_z_thickness) + ) + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 1.7 + else: + x_width = (12 - 1) * 9 + y_width = (8 - 1) * 9 + x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 + y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + dispense.container.get_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + well_bottoms = position.z + lld_search_height = well_bottoms + dispense.container.get_absolute_size_z() + 1.7 + + liquid_height = position.z + (dispense.liquid_height or 0) + 10 + + tip = next(t for t in dispense.tips if t is not None) + hlc = backend_params.hlc + if hlc is None: + hlc = get_vantage_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=backend_params.jet, + blow_out=backend_params.blow_out, + ) + + if backend_params.disable_volume_correction or hlc is None: + volume = dispense.volume + else: + volume = hlc.compute_corrected_volume(dispense.volume) + + type_of_dispensing_mode = backend_params.type_of_dispensing_mode + if type_of_dispensing_mode is None: + type_of_dispensing_mode = _get_dispense_mode( + jet=backend_params.jet, + empty=backend_params.empty, + blow_out=backend_params.blow_out, + ) + + transport_air_volume = backend_params.transport_air_volume or ( + hlc.dispense_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = backend_params.blow_out_air_volume or ( + hlc.dispense_blow_out_volume if hlc is not None else 0 + ) + flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) + swap_speed = backend_params.swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) + settling_time = backend_params.settling_time or ( + hlc.dispense_settling_time if hlc is not None else 5 + ) + + th = self.driver.traversal_height + + try: + await self._core96_dispensing_of_liquid( + type_of_dispensing_mode=type_of_dispensing_mode, + x_position=round(position.x * 10), + y_position=round(position.y * 10), + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round( + backend_params.tube_2nd_section_height_measured_from_zm * 10 + ), + tube_2nd_section_ratio=round(backend_params.tube_2nd_section_ratio * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), + pull_out_distance_to_take_transport_air_in_function_without_lld=round( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + ), + immersion_depth=round(backend_params.immersion_depth * 10), + surface_following_distance=round(backend_params.surface_following_distance * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimal_height_at_command_end or th) * 10 + ), + dispense_volume=round(volume * 100), + dispense_speed=round(flow_rate * 10), + cut_off_speed=round(backend_params.cut_off_speed * 10), + stop_back_volume=round(backend_params.stop_back_volume * 100), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 100), + lld_mode=backend_params.lld_mode, + lld_sensitivity=backend_params.lld_sensitivity, + side_touch_off_distance=round(backend_params.side_touch_off_distance * 10), + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(dispense.mix.volume * 100) if dispense.mix is not None else 0, + mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, + mix_position_in_z_direction_from_liquid_surface=0, + surface_following_distance_during_mixing=0, + mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 10, + limit_curve_index=backend_params.limit_curve_index, + tadm_channel_pattern=backend_params.tadm_channel_pattern, + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, + recording_mode=backend_params.recording_mode, + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + # -- firmware commands (A1HM) ---------------------------------------------- + + async def _core96_tip_pick_up( + self, + x_position: int, + y_position: int, + tip_type: int, + tip_handling_method: int, + z_deposit_position: int, + minimal_traverse_height_at_begin_of_command: int, + minimal_height_at_command_end: int, + ): + """Tip pick up using 96 head (A1HM:TP).""" + await self.driver.send_command( + module="A1HM", + command="TP", + xp=x_position, + yp=y_position, + tt=tip_type, + td=tip_handling_method, + tz=z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + async def _core96_tip_discard( + self, + x_position: int, + y_position: int, + z_deposit_position: int, + minimal_traverse_height_at_begin_of_command: int, + minimal_height_at_command_end: int, + ): + """Tip discard using 96 head (A1HM:TR).""" + await self.driver.send_command( + module="A1HM", + command="TR", + xp=x_position, + yp=y_position, + tz=z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + async def _core96_aspiration_of_liquid( + self, + type_of_aspiration: int, + x_position: int, + y_position: int, + minimal_traverse_height_at_begin_of_command: int, + minimal_height_at_command_end: int, + lld_search_height: int, + liquid_surface_at_function_without_lld: int, + pull_out_distance_to_take_transport_air_in_function_without_lld: int, + minimum_height: int, + tube_2nd_section_height_measured_from_zm: int, + tube_2nd_section_ratio: int, + immersion_depth: int, + surface_following_distance: int, + aspiration_volume: int, + aspiration_speed: int, + transport_air_volume: int, + blow_out_air_volume: int, + pre_wetting_volume: int, + lld_mode: int, + lld_sensitivity: int, + swap_speed: int, + settling_time: int, + mix_volume: int, + mix_cycles: int, + mix_position_in_z_direction_from_liquid_surface: int, + surface_following_distance_during_mixing: int, + mix_speed: int, + limit_curve_index: int, + tadm_channel_pattern: Optional[List[bool]], + tadm_algorithm_on_off: int, + recording_mode: int, + ): + """Aspiration of liquid using 96 head (A1HM:DA).""" + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + tadm_hex = _channel_pattern_to_hex(tadm_channel_pattern) + + await self.driver.send_command( + module="A1HM", + command="DA", + at=type_of_aspiration, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zx=minimum_height, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + ip=immersion_depth, + fp=surface_following_distance, + av=aspiration_volume, + as_=aspiration_speed, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + ll=lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + mh=surface_following_distance_during_mixing, + ms=mix_speed, + gi=limit_curve_index, + cw=tadm_hex, + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) + + async def _core96_dispensing_of_liquid( + self, + type_of_dispensing_mode: int, + x_position: int, + y_position: int, + minimum_height: int, + tube_2nd_section_height_measured_from_zm: int, + tube_2nd_section_ratio: int, + lld_search_height: int, + liquid_surface_at_function_without_lld: int, + pull_out_distance_to_take_transport_air_in_function_without_lld: int, + immersion_depth: int, + surface_following_distance: int, + minimal_traverse_height_at_begin_of_command: int, + minimal_height_at_command_end: int, + dispense_volume: int, + dispense_speed: int, + cut_off_speed: int, + stop_back_volume: int, + transport_air_volume: int, + blow_out_air_volume: int, + lld_mode: int, + lld_sensitivity: int, + side_touch_off_distance: int, + swap_speed: int, + settling_time: int, + mix_volume: int, + mix_cycles: int, + mix_position_in_z_direction_from_liquid_surface: int, + surface_following_distance_during_mixing: int, + mix_speed: int, + limit_curve_index: int, + tadm_channel_pattern: Optional[List[bool]], + tadm_algorithm_on_off: int, + recording_mode: int, + ): + """Dispensing of liquid using 96 head (A1HM:DD).""" + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + tadm_hex = _channel_pattern_to_hex(tadm_channel_pattern) + + await self.driver.send_command( + module="A1HM", + command="DD", + dm=type_of_dispensing_mode, + xp=x_position, + yp=y_position, + zx=minimum_height, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + ip=immersion_depth, + fp=surface_following_distance, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + dv=dispense_volume, + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + lm=lld_mode, + ll=lld_sensitivity, + dj=side_touch_off_distance, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + mh=surface_following_distance_during_mixing, + ms=mix_speed, + gi=limit_curve_index, + cw=tadm_hex, + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py new file mode 100644 index 00000000000..065abbad6e1 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py @@ -0,0 +1,279 @@ +"""Vantage IPG (Integrated Plate Gripper) backend: translates arm operations into firmware commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.capabilities.arms.backend import OrientableGripperArmBackend +from pylabrobot.capabilities.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from .driver import VantageDriver + + +def _direction_degrees_to_grip_orientation(degrees: float) -> int: + """Convert rotation angle in degrees to Vantage IPG grip orientation code. + + The IPG uses numeric codes 1-44 for various orientations. The primary ones used for + plate manipulation are: + 32 = front grip (default) + 11 = right grip (90 degrees) + 31 = back grip (180 degrees) + 12 = left grip (270 degrees) + """ + normalized = round(degrees) % 360 + mapping = {0: 32, 90: 11, 180: 31, 270: 12} + if normalized not in mapping: + raise ValueError(f"grip direction must be a multiple of 90 degrees, got {degrees}") + return mapping[normalized] + + +class IPGBackend(OrientableGripperArmBackend): + """Backend for the Vantage Integrated Plate Gripper (IPG). + + Implements OrientableGripperArmBackend, translating arm operations into + firmware commands on module A1RM. + """ + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + self._parked: bool = False + + @property + def parked(self) -> bool: + return self._parked + + async def _on_setup(self) -> None: + pass + + async def _on_stop(self) -> None: + if not self._parked: + try: + await self.park() + except Exception: + pass + + # -- BackendParams --------------------------------------------------------- + + @dataclass + class PickUpParams(BackendParams): + grip_strength: int = 100 + open_gripper_position: int = 860 + plate_width_tolerance: int = 20 + acceleration_index: int = 4 + z_clearance_height: int = 50 + hotel_depth: int = 0 + minimal_height_at_command_end: int = 3600 + + @dataclass + class DropParams(BackendParams): + open_gripper_position: int = 860 + z_clearance_height: int = 50 + press_on_distance: int = 5 + hotel_depth: int = 0 + minimal_height_at_command_end: int = 3600 + + # -- OrientableGripperArmBackend interface --------------------------------- + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, IPGBackend.PickUpParams): + backend_params = IPGBackend.PickUpParams() + + grip_orientation = _direction_degrees_to_grip_orientation(direction) + th = round(self.driver.traversal_height * 10) + + await self._ipg_prepare_gripper_orientation( + grip_orientation=grip_orientation, + minimal_traverse_height_at_begin_of_command=th, + ) + + await self._ipg_grip_plate( + x_position=round(location.x * 10), + y_position=round(location.y * 10), + z_position=round(location.z * 10), + grip_strength=backend_params.grip_strength, + open_gripper_position=backend_params.open_gripper_position, + plate_width=round(resource_width * 10), + plate_width_tolerance=backend_params.plate_width_tolerance, + acceleration_index=backend_params.acceleration_index, + z_clearance_height=backend_params.z_clearance_height, + hotel_depth=backend_params.hotel_depth, + minimal_height_at_command_end=backend_params.minimal_height_at_command_end, + ) + self._parked = False + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, IPGBackend.DropParams): + backend_params = IPGBackend.DropParams() + + await self._ipg_put_plate( + x_position=round(location.x * 10), + y_position=round(location.y * 10), + z_position=round(location.z * 10), + open_gripper_position=backend_params.open_gripper_position, + z_clearance_height=backend_params.z_clearance_height, + hotel_depth=backend_params.hotel_depth, + minimal_height_at_command_end=backend_params.minimal_height_at_command_end, + ) + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + th = round(self.driver.traversal_height * 10) + await self._ipg_move_to_defined_position( + x_position=round(location.x * 10), + y_position=round(location.y * 10), + z_position=round(location.z * 10), + minimal_traverse_height_at_begin_of_command=th, + ) + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + pass # No explicit halt command for IPG. + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + await self.driver.send_command(module="A1RM", command="GP") + self._parked = True + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + raise NotImplementedError( + "request_gripper_location is not yet implemented for the Vantage IPG. " + "The firmware response format for A1RM:QI needs to be reverse-engineered." + ) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.driver.send_command(module="A1RM", command="DO") + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + # Closing is handled implicitly by grip_plate with the desired width. + pass + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return not self._parked + + # -- Initialization and status queries ------------------------------------- + + async def request_initialization_status(self) -> bool: + """Check if the IPG module is initialized (A1RM:QW).""" + resp = await self.driver.send_command(module="A1RM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def initialize(self) -> None: + """Initialize the IPG (A1RM:DI).""" + await self.driver.send_command(module="A1RM", command="DI") + + async def get_parking_status(self) -> bool: + """Check if the IPG is parked (A1RM:RG). Returns True if parked.""" + resp = await self.driver.send_command(module="A1RM", command="RG", fmt={"rg": "int"}) + parked = resp is not None and resp["rg"] == 1 + self._parked = parked + return parked + + # -- firmware commands (A1RM) ---------------------------------------------- + + async def _ipg_prepare_gripper_orientation( + self, + grip_orientation: int = 32, + minimal_traverse_height_at_begin_of_command: int = 3600, + ) -> None: + """Prepare gripper orientation (A1RM:GA).""" + await self.driver.send_command( + module="A1RM", + command="GA", + gd=grip_orientation, + th=minimal_traverse_height_at_begin_of_command, + ) + + async def _ipg_grip_plate( + self, + x_position: int, + y_position: int, + z_position: int, + grip_strength: int = 100, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + acceleration_index: int = 4, + z_clearance_height: int = 50, + hotel_depth: int = 0, + minimal_height_at_command_end: int = 3600, + ) -> None: + """Grip plate (A1RM:DG).""" + await self.driver.send_command( + module="A1RM", + command="DG", + xp=x_position, + yp=y_position, + zp=z_position, + yw=grip_strength, + yo=open_gripper_position, + yg=plate_width, + pt=plate_width_tolerance, + ai=acceleration_index, + zc=z_clearance_height, + hd=hotel_depth, + te=minimal_height_at_command_end, + ) + + async def _ipg_put_plate( + self, + x_position: int, + y_position: int, + z_position: int, + open_gripper_position: int = 860, + z_clearance_height: int = 50, + hotel_depth: int = 0, + minimal_height_at_command_end: int = 3600, + ) -> None: + """Put plate (A1RM:DR).""" + await self.driver.send_command( + module="A1RM", + command="DR", + xp=x_position, + yp=y_position, + zp=z_position, + yo=open_gripper_position, + zc=z_clearance_height, + hd=hotel_depth, + te=minimal_height_at_command_end, + ) + + async def _ipg_move_to_defined_position( + self, + x_position: int, + y_position: int, + z_position: int, + minimal_traverse_height_at_begin_of_command: int = 3600, + ) -> None: + """Move to defined position (A1RM:DN).""" + await self.driver.send_command( + module="A1RM", + command="DN", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py b/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py new file mode 100644 index 00000000000..c85b61d7f0f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py @@ -0,0 +1,56 @@ +"""VantageLoadingCover: loading cover control for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import VantageDriver + + +class VantageLoadingCover: + """Controls the loading cover on a Hamilton Vantage. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for loading cover control and delegates I/O to the driver. + """ + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- commands (I1AM) ------------------------------------------------------- + + async def request_initialization_status(self) -> bool: + """Check if the loading cover module is initialized (I1AM:QW). + + Returns: + True if initialized, False otherwise. + """ + resp = await self.driver.send_command(module="I1AM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def initialize(self) -> None: + """Initialize the loading cover module (I1AM:MI).""" + await self.driver.send_command(module="I1AM", command="MI") + + async def set_cover(self, cover_open: bool) -> None: + """Open or close the loading cover (I1AM:LP). + + Args: + cover_open: True to open, False to close. + """ + await self.driver.send_command(module="I1AM", command="LP", lp=not cover_open) + + async def open(self) -> None: + """Open the loading cover.""" + await self.set_cover(cover_open=True) + + async def close(self) -> None: + """Close the loading cover.""" + await self.set_cover(cover_open=False) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py new file mode 100644 index 00000000000..25da9ce4f3c --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -0,0 +1,822 @@ +"""Vantage PIP backend: translates PIP operations into Vantage firmware commands.""" + +from __future__ import annotations + +import enum +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.hamilton.lh.vantage.liquid_classes import get_vantage_liquid_class +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.resources import Resource, Well +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.liquid import Liquid + +from .errors import VantageFirmwareError, convert_vantage_firmware_error_to_plr_error + +if TYPE_CHECKING: + from .driver import VantageDriver + +logger = logging.getLogger("pylabrobot") + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LLDMode(enum.Enum): + """Liquid level detection mode.""" + + OFF = 0 + GAMMA = 1 + PRESSURE = 2 + DUAL = 3 + Z_TOUCH_OFF = 4 + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + + +def _get_dispense_mode(jet: bool, empty: bool, blow_out: bool) -> int: + """Compute firmware dispensing mode from boolean flags. + + Firmware modes: + 0 = Partial volume in jet mode + 1 = Blow out in jet mode (labelled "empty" in VENUS) + 2 = Partial volume at surface + 3 = Blow out at surface (labelled "empty" in VENUS) + 4 = Empty tip at fix position + """ + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +def _ops_to_fw_positions( + ops: Sequence[Union[Pickup, TipDrop, Aspiration, Dispense]], + use_channels: List[int], + num_channels: int, +) -> Tuple[List[int], List[int], List[bool]]: + """Convert ops + use_channels into firmware x/y positions and tip pattern. + + Uses absolute coordinates so the driver does not need a ``deck`` reference. + """ + if use_channels != sorted(use_channels): + raise ValueError("Channels must be sorted.") + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + + for i, channel in enumerate(use_channels): + while channel > len(channels_involved): + channels_involved.append(False) + x_positions.append(0) + y_positions.append(0) + channels_involved.append(True) + + loc = ops[i].resource.get_absolute_location(x="c", y="c", z="b") + x_positions.append(round((loc.x + ops[i].offset.x) * 10)) + y_positions.append(round((loc.y + ops[i].offset.y) * 10)) + + # Minimum distance check (9mm). + for idx1, (x1, y1) in enumerate(zip(x_positions, y_positions)): + for idx2, (x2, y2) in enumerate(zip(x_positions, y_positions)): + if idx1 == idx2: + continue + if not channels_involved[idx1] or not channels_involved[idx2]: + continue + if x1 != x2: + continue + if y1 != y2 and abs(y1 - y2) < 90: + raise ValueError( + f"Minimum distance between two y positions is <9mm: {y1}, {y2}" + f" (channel {idx1} and {idx2})" + ) + + if len(ops) > num_channels: + raise ValueError(f"Too many channels specified: {len(ops)} > {num_channels}") + + # Trailing padding. + if len(x_positions) < num_channels: + x_positions = x_positions + [0] + y_positions = y_positions + [0] + channels_involved = channels_involved + [False] + + return x_positions, y_positions, channels_involved + + +def _resolve_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + jet: Union[bool, List[bool]], + blow_out: Union[bool, List[bool]], +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. Auto-detect from tip if explicit is None.""" + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + get_vantage_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + return result + + +# --------------------------------------------------------------------------- +# VantagePIPBackend +# --------------------------------------------------------------------------- + + +class VantagePIPBackend(PIPBackend): + """Translates PIP operations into Vantage firmware commands via the driver.""" + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + @property + def num_channels(self) -> int: + return self.driver.num_channels + + # -- BackendParams dataclasses --------------------------------------------- + + @dataclass + class PickUpTipsParams(BackendParams): + """Vantage-specific parameters for ``pick_up_tips``.""" + + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None + minimal_height_at_command_end: Optional[List[float]] = None + + @dataclass + class DropTipsParams(BackendParams): + """Vantage-specific parameters for ``drop_tips``.""" + + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None + minimal_height_at_command_end: Optional[List[float]] = None + + @dataclass + class AspirateParams(BackendParams): + """Vantage-specific parameters for ``aspirate``.""" + + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None + type_of_aspiration: Optional[List[int]] = None + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None + minimal_height_at_command_end: Optional[List[float]] = None + lld_search_height: Optional[List[float]] = None + clot_detection_height: Optional[List[float]] = None + liquid_surface_at_function_without_lld: Optional[List[float]] = None + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None + tube_2nd_section_ratio: Optional[List[float]] = None + minimum_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + pre_wetting_volume: Optional[List[float]] = None + lld_mode: Optional[List[int]] = None + lld_sensitivity: Optional[List[int]] = None + pressure_lld_sensitivity: Optional[List[int]] = None + aspirate_position_above_z_touch_off: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + settling_time: Optional[List[float]] = None + capacitive_mad_supervision_on_off: Optional[List[int]] = None + pressure_mad_supervision_on_off: Optional[List[int]] = None + tadm_algorithm_on_off: int = 0 + limit_curve_index: Optional[List[int]] = None + recording_mode: int = 0 + disable_volume_correction: Optional[List[bool]] = None + + @dataclass + class DispenseParams(BackendParams): + """Vantage-specific parameters for ``dispense``.""" + + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + empty: Optional[List[bool]] = None + hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None + type_of_dispensing_mode: Optional[List[int]] = None + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None + minimal_height_at_command_end: Optional[List[float]] = None + lld_search_height: Optional[List[float]] = None + minimum_height: Optional[List[float]] = None + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None + tube_2nd_section_ratio: Optional[List[float]] = None + cut_off_speed: Optional[List[float]] = None + stop_back_volume: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + lld_mode: Optional[List[int]] = None + side_touch_off_distance: float = 0 + dispense_position_above_z_touch_off: Optional[List[float]] = None + lld_sensitivity: Optional[List[int]] = None + pressure_lld_sensitivity: Optional[List[int]] = None + swap_speed: Optional[List[float]] = None + settling_time: Optional[List[float]] = None + tadm_algorithm_on_off: int = 0 + limit_curve_index: Optional[List[int]] = None + recording_mode: int = 0 + disable_volume_correction: Optional[List[bool]] = None + + # -- PIPBackend interface: pick_up_tips ------------------------------------ + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantagePIPBackend.PickUpTipsParams): + backend_params = VantagePIPBackend.PickUpTipsParams() + + x_positions, y_positions, tip_pattern = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + tips = [cast(HamiltonTip, op.resource.get_tip()) for op in ops] + ttti = [await self.driver.request_or_assign_tip_type_index(tip) for tip in tips] + + max_z = max(op.resource.get_absolute_location(z="b").z + op.offset.z for op in ops) + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + + # Tip size adjustments (from legacy, confirmed by experiments). + proto_tip = self.driver._get_hamilton_tip([op.resource for op in ops]) + if proto_tip.tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif proto_tip.tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + th = self.driver.traversal_height + mth = backend_params.minimal_traverse_height_at_begin_of_command + mhe = backend_params.minimal_height_at_command_end + + try: + await self._pip_tip_pick_up( + x_position=x_positions, + y_position=y_positions, + tip_pattern=tip_pattern, + tip_type=ttti, + begin_z_deposit_position=[round((max_z + max_total_tip_length) * 10)] * len(ops), + end_z_deposit_position=[round((max_z + max_tip_length) * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), + minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + tip_handling_method=[1] * len(ops), + blow_out_air_volume=[0] * len(ops), + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + # -- PIPBackend interface: drop_tips --------------------------------------- + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantagePIPBackend.DropTipsParams): + backend_params = VantagePIPBackend.DropTipsParams() + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + max_z = max(op.resource.get_absolute_location(z="b").z + op.offset.z for op in ops) + th = self.driver.traversal_height + mth = backend_params.minimal_traverse_height_at_begin_of_command + mhe = backend_params.minimal_height_at_command_end + + try: + await self._pip_tip_discard( + x_position=x_positions, + y_position=y_positions, + tip_pattern=channels_involved, + begin_z_deposit_position=[round((max_z + 10) * 10)] * len(ops), + end_z_deposit_position=[round(max_z * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), + minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + tip_handling_method=[0] * len(ops), + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + # -- safety checks ---------------------------------------------------------- + + @staticmethod + def _assert_valid_resources(resources: List[Resource]) -> None: + """Assert that resources are not too low for safe pipetting.""" + for resource in resources: + if resource.get_absolute_location(z="b").z < 100: + raise ValueError( + f"Resource {resource} is too low: {resource.get_absolute_location(z='b').z} < 100" + ) + + # -- PIPBackend interface: aspirate ---------------------------------------- + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantagePIPBackend.AspirateParams): + backend_params = VantagePIPBackend.AspirateParams() + + self._assert_valid_resources([op.resource for op in ops]) + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + jet = backend_params.jet or [False] * len(ops) + blow_out = backend_params.blow_out or [False] * len(ops) + hlcs = _resolve_liquid_classes(backend_params.hlcs, ops, jet, blow_out) + + # Volume correction. + disable_vc = backend_params.disable_volume_correction or [False] * len(ops) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_vc) + ] + + well_bottoms = [ + op.resource.get_absolute_location(z="b").z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + liquid_surfaces_no_lld = backend_params.liquid_surface_at_function_without_lld or [ + wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops) + ] + lld_search_heights = backend_params.lld_search_height or [ + wb + op.resource.get_absolute_size_z() + (1.7 if isinstance(op.resource, Well) else 5) + for wb, op in zip(well_bottoms, ops) + ] + + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) + for op, hlc in zip(ops, hlcs) + ] + blow_out_air_volumes = [ + op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0) + for op, hlc in zip(ops, hlcs) + ] + + th = self.driver.traversal_height + mth = backend_params.minimal_traverse_height_at_begin_of_command + mhe = backend_params.minimal_height_at_command_end + + try: + await self._pip_aspirate( + x_position=x_positions, + y_position=y_positions, + type_of_aspiration=backend_params.type_of_aspiration or [0] * len(ops), + tip_pattern=channels_involved, + minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), + minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + lld_search_height=[round(ls * 10) for ls in lld_search_heights], + clot_detection_height=[ + round(cdh * 10) for cdh in backend_params.clot_detection_height or [0] * len(ops) + ], + liquid_surface_at_function_without_lld=[round(lsn * 10) for lsn in liquid_surfaces_no_lld], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + round(pod * 10) + for pod in backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + or [10.9] * len(ops) + ], + tube_2nd_section_height_measured_from_zm=[ + round(t * 10) + for t in backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ], + tube_2nd_section_ratio=[ + round(t * 10) for t in backend_params.tube_2nd_section_ratio or [0] * len(ops) + ], + minimum_height=[round(wb * 10) for wb in backend_params.minimum_height or well_bottoms], + immersion_depth=[round(d * 10) for d in backend_params.immersion_depth or [0] * len(ops)], + surface_following_distance=[ + round(d * 10) for d in backend_params.surface_following_distance or [0] * len(ops) + ], + aspiration_volume=[round(vol * 100) for vol in volumes], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[ + round(tav * 10) + for tav in backend_params.transport_air_volume + or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ], + blow_out_air_volume=[round(bav * 100) for bav in blow_out_air_volumes], + pre_wetting_volume=[ + round(pwv * 100) for pwv in backend_params.pre_wetting_volume or [0] * len(ops) + ], + lld_mode=backend_params.lld_mode or [0] * len(ops), + lld_sensitivity=backend_params.lld_sensitivity or [4] * len(ops), + pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [4] * len(ops), + aspirate_position_above_z_touch_off=[ + round(apz * 10) + for apz in backend_params.aspirate_position_above_z_touch_off or [0.5] * len(ops) + ], + swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [2] * len(ops)], + settling_time=[round(st * 10) for st in backend_params.settling_time or [1] * len(ops)], + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], + mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), + mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 2500 for op in ops], + surface_following_distance_during_mixing=[0] * len(ops), + capacitive_mad_supervision_on_off=( + backend_params.capacitive_mad_supervision_on_off or [0] * len(ops) + ), + pressure_mad_supervision_on_off=( + backend_params.pressure_mad_supervision_on_off or [0] * len(ops) + ), + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, + limit_curve_index=backend_params.limit_curve_index or [0] * len(ops), + recording_mode=backend_params.recording_mode, + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + # -- PIPBackend interface: dispense ---------------------------------------- + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, VantagePIPBackend.DispenseParams): + backend_params = VantagePIPBackend.DispenseParams() + + self._assert_valid_resources([op.resource for op in ops]) + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + jet = backend_params.jet or [False] * len(ops) + empty = backend_params.empty or [False] * len(ops) + blow_out = backend_params.blow_out or [False] * len(ops) + hlcs = _resolve_liquid_classes(backend_params.hlcs, ops, jet, blow_out) + + # Volume correction. + disable_vc = backend_params.disable_volume_correction or [False] * len(ops) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_vc) + ] + + well_bottoms = [ + op.resource.get_absolute_location(z="b").z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + lld_search_heights = backend_params.lld_search_height or [ + wb + op.resource.get_absolute_size_z() + (1.7 if isinstance(op.resource, Well) else 5) + for wb, op in zip(well_bottoms, ops) + ] + + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100) + for op, hlc in zip(ops, hlcs) + ] + blow_out_air_volumes = [ + op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0) + for op, hlc in zip(ops, hlcs) + ] + + type_of_dispensing_mode = backend_params.type_of_dispensing_mode or [ + _get_dispense_mode(jet=jet[i], empty=empty[i], blow_out=blow_out[i]) for i in range(len(ops)) + ] + + th = self.driver.traversal_height + mth = backend_params.minimal_traverse_height_at_begin_of_command + mhe = backend_params.minimal_height_at_command_end + + try: + await self._pip_dispense( + x_position=x_positions, + y_position=y_positions, + tip_pattern=channels_involved, + type_of_dispensing_mode=type_of_dispensing_mode, + minimum_height=[round(wb * 10) for wb in backend_params.minimum_height or well_bottoms], + lld_search_height=[round(sh * 10) for sh in lld_search_heights], + liquid_surface_at_function_without_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + round(pod * 10) + for pod in backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + or [5.0] * len(ops) + ], + immersion_depth=[round(d * 10) for d in backend_params.immersion_depth or [0] * len(ops)], + surface_following_distance=[ + round(d * 10) for d in backend_params.surface_following_distance or [2.1] * len(ops) + ], + tube_2nd_section_height_measured_from_zm=[ + round(t * 10) + for t in backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ], + tube_2nd_section_ratio=[ + round(t * 10) for t in backend_params.tube_2nd_section_ratio or [0] * len(ops) + ], + minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), + minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + dispense_volume=[round(vol * 100) for vol in volumes], + dispense_speed=[round(fr * 10) for fr in flow_rates], + cut_off_speed=[round(cs * 10) for cs in backend_params.cut_off_speed or [250] * len(ops)], + stop_back_volume=[ + round(sbv * 100) for sbv in backend_params.stop_back_volume or [0] * len(ops) + ], + transport_air_volume=[ + round(tav * 10) + for tav in backend_params.transport_air_volume + or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ], + blow_out_air_volume=[round(boav * 100) for boav in blow_out_air_volumes], + lld_mode=backend_params.lld_mode or [0] * len(ops), + side_touch_off_distance=round(backend_params.side_touch_off_distance * 10), + dispense_position_above_z_touch_off=[ + round(dpz * 10) + for dpz in backend_params.dispense_position_above_z_touch_off or [0.5] * len(ops) + ], + lld_sensitivity=backend_params.lld_sensitivity or [1] * len(ops), + pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [1] * len(ops), + swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [1] * len(ops)], + settling_time=[round(st * 10) for st in backend_params.settling_time or [0] * len(ops)], + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], + mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), + mix_speed=[round(op.mix.flow_rate * 100) if op.mix is not None else 10 for op in ops], + surface_following_distance_during_mixing=[0] * len(ops), + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, + limit_curve_index=backend_params.limit_curve_index or [0] * len(ops), + recording_mode=backend_params.recording_mode, + ) + except VantageFirmwareError as e: + plr_error = convert_vantage_firmware_error_to_plr_error(e) + raise plr_error if plr_error is not None else e + + # -- tip presence ---------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + presences = await self.driver.query_tip_presence() + return [bool(p) for p in presences] + + # -- firmware commands (A1PM) ---------------------------------------------- + + async def _pip_tip_pick_up( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: List[bool], + tip_type: List[int], + begin_z_deposit_position: List[int], + end_z_deposit_position: List[int], + minimal_traverse_height_at_begin_of_command: List[int], + minimal_height_at_command_end: List[int], + tip_handling_method: List[int], + blow_out_air_volume: List[int], + ): + """Tip pick up (A1PM:TP).""" + await self.driver.send_command( + module="A1PM", + command="TP", + tip_pattern=tip_pattern, + xp=x_position, + yp=y_position, + tm=tip_pattern, + tt=tip_type, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ba=blow_out_air_volume, + td=tip_handling_method, + ) + + async def _pip_tip_discard( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: List[bool], + begin_z_deposit_position: List[int], + end_z_deposit_position: List[int], + minimal_traverse_height_at_begin_of_command: List[int], + minimal_height_at_command_end: List[int], + tip_handling_method: List[int], + ts: int = 0, + ): + """Tip discard (A1PM:TR).""" + await self.driver.send_command( + module="A1PM", + command="TR", + tip_pattern=tip_pattern, + xp=x_position, + yp=y_position, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + tm=tip_pattern, + ts=ts, + td=tip_handling_method, + ) + + async def _pip_aspirate( + self, + x_position: List[int], + y_position: List[int], + type_of_aspiration: List[int], + tip_pattern: List[bool], + minimal_traverse_height_at_begin_of_command: List[int], + minimal_height_at_command_end: List[int], + lld_search_height: List[int], + clot_detection_height: List[int], + liquid_surface_at_function_without_lld: List[int], + pull_out_distance_to_take_transport_air_in_function_without_lld: List[int], + tube_2nd_section_height_measured_from_zm: List[int], + tube_2nd_section_ratio: List[int], + minimum_height: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + aspiration_volume: List[int], + aspiration_speed: List[int], + transport_air_volume: List[int], + blow_out_air_volume: List[int], + pre_wetting_volume: List[int], + lld_mode: List[int], + lld_sensitivity: List[int], + pressure_lld_sensitivity: List[int], + aspirate_position_above_z_touch_off: List[int], + swap_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_in_z_direction_from_liquid_surface: List[int], + mix_speed: List[int], + surface_following_distance_during_mixing: List[int], + capacitive_mad_supervision_on_off: List[int], + pressure_mad_supervision_on_off: List[int], + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + """Aspiration of liquid (A1PM:DA).""" + await self.driver.send_command( + module="A1PM", + command="DA", + tip_pattern=tip_pattern, + at=type_of_aspiration, + tm=tip_pattern, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + ch=clot_detection_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + zx=minimum_height, + ip=immersion_depth, + fp=surface_following_distance, + av=aspiration_volume, + as_=aspiration_speed, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + zo=aspirate_position_above_z_touch_off, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=[0] * len(x_position), + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index or [0] * len(x_position), + gk=recording_mode, + ) + + async def _pip_dispense( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: List[bool], + type_of_dispensing_mode: List[int], + minimum_height: List[int], + lld_search_height: List[int], + liquid_surface_at_function_without_lld: List[int], + pull_out_distance_to_take_transport_air_in_function_without_lld: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + tube_2nd_section_height_measured_from_zm: List[int], + tube_2nd_section_ratio: List[int], + minimal_traverse_height_at_begin_of_command: List[int], + minimal_height_at_command_end: List[int], + dispense_volume: List[int], + dispense_speed: List[int], + cut_off_speed: List[int], + stop_back_volume: List[int], + transport_air_volume: List[int], + blow_out_air_volume: List[int], + lld_mode: List[int], + side_touch_off_distance: int, + dispense_position_above_z_touch_off: List[int], + lld_sensitivity: List[int], + pressure_lld_sensitivity: List[int], + swap_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_in_z_direction_from_liquid_surface: List[int], + mix_speed: List[int], + surface_following_distance_during_mixing: List[int], + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + """Dispensing of liquid (A1PM:DD).""" + await self.driver.send_command( + module="A1PM", + command="DD", + tip_pattern=tip_pattern, + dm=type_of_dispensing_mode, + tm=tip_pattern, + xp=x_position, + yp=y_position, + zx=minimum_height, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + ip=immersion_depth, + fp=surface_following_distance, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + dv=[f"{vol:04}" for vol in dispense_volume], + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + lm=lld_mode, + dj=side_touch_off_distance, + zo=dispense_position_above_z_touch_off, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=[0] * len(x_position), + gj=tadm_algorithm_on_off, + gi=limit_curve_index or [0] * len(x_position), + gk=recording_mode, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py new file mode 100644 index 00000000000..c562f91c425 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py @@ -0,0 +1,75 @@ +"""Tests for Vantage error handling.""" + +import unittest + +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.hamilton.liquid_handlers.vantage.errors import ( + VantageFirmwareError, + convert_vantage_firmware_error_to_plr_error, + vantage_response_string_to_error, +) +from pylabrobot.resources.errors import HasTipError, NoTipError + + +class TestVantageResponseStringToError(unittest.TestCase): + def test_pip_error(self): + error_str = 'A1PMDAid1234er1es"P175"' + error = vantage_response_string_to_error(error_str) + self.assertIsInstance(error, VantageFirmwareError) + self.assertIn("Pipetting channel 1", error.errors) + self.assertEqual(error.errors["Pipetting channel 1"], "No tip picked up") + + def test_core96_error(self): + error_str = 'A1HMDAid1234er1es"H075"' + error = vantage_response_string_to_error(error_str) + self.assertIsInstance(error, VantageFirmwareError) + self.assertIn("Core 96", error.errors) + self.assertEqual(error.errors["Core 96"], "No tip picked up") + + def test_et_format_error(self): + error_str = 'A1PMDAid1234et"some error text"' + error = vantage_response_string_to_error(error_str) + self.assertIsInstance(error, VantageFirmwareError) + self.assertIn("Pip", error.errors) + self.assertEqual(error.errors["Pip"], "some error text") + + def test_error_equality(self): + e1 = VantageFirmwareError({"ch": "test"}, "raw") + e2 = VantageFirmwareError({"ch": "test"}, "raw") + self.assertEqual(e1, e2) + + def test_error_str(self): + e = VantageFirmwareError({"ch": "test"}, "raw") + self.assertIn("VantageFirmwareError", str(e)) + + +class TestConvertToPLRError(unittest.TestCase): + def test_tip_already_picked_up(self): + error = VantageFirmwareError( + {"Pipetting channel 1": "Tip already picked up"}, + "raw", + ) + result = convert_vantage_firmware_error_to_plr_error(error) + assert isinstance(result, ChannelizedError) + self.assertIsInstance(result.errors[0], HasTipError) + + def test_no_tip_picked_up(self): + error = VantageFirmwareError( + {"Pipetting channel 1": "No tip picked up"}, + "raw", + ) + result = convert_vantage_firmware_error_to_plr_error(error) + assert isinstance(result, ChannelizedError) + self.assertIsInstance(result.errors[0], NoTipError) + + def test_non_channel_error_returns_none(self): + error = VantageFirmwareError( + {"Core 96": "No tip picked up"}, + "raw", + ) + result = convert_vantage_firmware_error_to_plr_error(error) + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py new file mode 100644 index 00000000000..487bdbcedda --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py @@ -0,0 +1,45 @@ +"""Tests for Vantage firmware response parsing.""" + +import unittest + +from pylabrobot.hamilton.liquid_handlers.vantage.fw_parsing import parse_vantage_fw_string + + +class TestParseVantageFWString(unittest.TestCase): + def test_parse_id(self): + result = parse_vantage_fw_string("A1PMDAid1234") + self.assertEqual(result["id"], 1234) + + def test_parse_int(self): + result = parse_vantage_fw_string("A1PMDAid0qw1", {"qw": "int"}) + self.assertEqual(result["id"], 0) + self.assertEqual(result["qw"], 1) + + def test_parse_str(self): + result = parse_vantage_fw_string('id0es"error string"', {"es": "str"}) + self.assertEqual(result["es"], "error string") + + def test_parse_int_list(self): + result = parse_vantage_fw_string("id0xs30 -100 +1 1000", {"xs": "[int]"}) + self.assertEqual(result["id"], 0) + self.assertEqual(result["xs"], [30, -100, 1, 1000]) + + def test_parse_hex(self): + result = parse_vantage_fw_string("id0cwFF", {"cw": "hex"}) + self.assertEqual(result["cw"], 255) + + def test_invalid_fmt_type(self): + with self.assertRaises(TypeError): + parse_vantage_fw_string("id0", "invalid") # type: ignore + + def test_unknown_data_type(self): + with self.assertRaises(ValueError): + parse_vantage_fw_string("id0foo1", {"foo": "unknown"}) + + def test_no_match_raises(self): + with self.assertRaises(ValueError): + parse_vantage_fw_string("id0", {"qw": "int"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py new file mode 100644 index 00000000000..9240e3eabef --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py @@ -0,0 +1,61 @@ +"""Vantage device: wires VantageDriver backends to PIP/Head96/IPG capability frontends.""" + +from typing import Optional + +from pylabrobot.capabilities.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.liquid_handling.head96 import Head96 +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources.hamilton.vantage_decks import VantageDeck + +from .chatterbox import VantageChatterboxDriver +from .driver import VantageDriver + + +class Vantage(Device): + """Hamilton Vantage liquid handler. + + User-facing device that wires capability frontends (PIP, Head96, IPG) to the + VantageDriver's backends after hardware discovery during setup(). + """ + + def __init__(self, deck: VantageDeck, chatterbox: bool = False): + driver = VantageChatterboxDriver() if chatterbox else VantageDriver() + super().__init__(driver=driver) + self.driver: VantageDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + self.head96: Optional[Head96] = None # set in setup() if installed + self.ipg: Optional[OrientableArm] = None # set in setup() if installed + + async def setup(self): + await self.driver.setup() + + # PIP is always present. + assert self.driver.pip is not None + self.pip = PIP(backend=self.driver.pip) + self._capabilities = [self.pip] + + # Head96 only if the hardware has a 96-head installed. + if self.driver.head96 is not None: + self.head96 = Head96(backend=self.driver.head96) + self._capabilities.append(self.head96) + + # IPG only if installed. + if self.driver.ipg is not None: + self.ipg = OrientableArm(backend=self.driver.ipg, reference_resource=self.deck) + self._capabilities.append(self.ipg) + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + + async def stop(self): + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + self.head96 = None + self.ipg = None diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py b/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py new file mode 100644 index 00000000000..16b8ac37f1f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py @@ -0,0 +1,125 @@ +"""VantageXArm: X-arm positioning control for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import VantageDriver + + +class VantageXArm: + """Controls the X-arm on a Hamilton Vantage. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for X-arm positioning and delegates I/O to the driver. + """ + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- commands (A1XM) ------------------------------------------------------- + + async def initialize(self) -> None: + """Initialize the X-arm (A1XM:XI).""" + await self.driver.send_command(module="A1XM", command="XI") + + async def move_to_x_position( + self, + x_position: int = 5000, + x_speed: int = 25000, + ) -> None: + """Move arm to X position (A1XM:XP). + + Args: + x_position: X position [0.1mm]. Range -50000 to 50000. + x_speed: X speed [0.1mm/s]. Range 1 to 25000. + """ + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + await self.driver.send_command(module="A1XM", command="XP", xp=x_position, xv=x_speed) + + async def move_to_x_position_safe( + self, + x_position: int = 5000, + x_speed: int = 25000, + xx: int = 1, + ) -> None: + """Move arm to X position with all attached components in Z-safety (A1XM:XA). + + Args: + x_position: X position [0.1mm]. + x_speed: X speed [0.1mm/s]. + xx: Unknown parameter. + """ + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + await self.driver.send_command(module="A1XM", command="XA", xp=x_position, xv=x_speed, xx=xx) + + async def move_relatively( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + xx: int = 1, + ) -> None: + """Move arm relatively in X (A1XM:XS). + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + xx: Unknown parameter. + """ + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + await self.driver.send_command( + module="A1XM", command="XS", xs=x_search_distance, xv=x_speed, xx=xx + ) + + async def search_teach_signal( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + xx: int = 1, + ) -> None: + """Search X for teach signal (A1XM:XT). + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + xx: Unknown parameter. + """ + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + await self.driver.send_command( + module="A1XM", command="XT", xs=x_search_distance, xv=x_speed, xx=xx + ) + + async def turn_off(self) -> None: + """Turn X drive off (A1XM:XO).""" + await self.driver.send_command(module="A1XM", command="XO") + + async def request_position(self): + """Request arm X position (A1XM:RX).""" + return await self.driver.send_command(module="A1XM", command="RX") + + async def request_error_code(self): + """Request X-arm error code (A1XM:RE).""" + return await self.driver.send_command(module="A1XM", command="RE") From 2ddcdf4a3c77a24b90340305d9c23a525e01b19b Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Apr 2026 19:25:36 -0700 Subject: [PATCH 02/11] Legacy VantageBackend delegates to new VantageDriver Follows the same pattern as STARBackend: the legacy class creates a VantageDriver in __init__, delegates send_command/setup/stop to it, and exposes typed property accessors for the new subsystems. All 21 legacy tests pass unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backends/hamilton/vantage_backend.py | 138 ++++++++++++------ 1 file changed, 97 insertions(+), 41 deletions(-) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index 07c02e4fc0c..a34968af2bb 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -374,9 +374,61 @@ def __init__( serial_number=serial_number, ) + from pylabrobot.hamilton.liquid_handlers.vantage.driver import VantageDriver + + self.driver = VantageDriver( + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + self._iswap_parked: Optional[bool] = None self._num_channels: Optional[int] = None self._traversal_height: float = 245.0 + self._setup_done = False + + # -- property accessors for new-arch subsystems ---------------------------- + + @property + def _vantage_pip(self): + """Typed access to the Vantage PIP backend.""" + return self.driver.pip + + @property + def _vantage_head96(self): + """Typed access to the Head96 backend.""" + assert self.driver.head96 is not None, "96-head is not installed" + return self.driver.head96 + + @property + def _vantage_ipg(self): + """Typed access to the IPG backend.""" + assert self.driver.ipg is not None, "IPG is not installed" + return self.driver.ipg + + @property + def _vantage_x_arm(self): + """Typed access to the X-arm.""" + assert self.driver.x_arm is not None, "X-arm is not available" + return self.driver.x_arm + + @property + def _vantage_loading_cover(self): + """Typed access to the loading cover.""" + assert self.driver.loading_cover is not None, "Loading cover is not available" + return self.driver.loading_cover + + @property + def _write_and_read_command(self): + return self.driver._write_and_read_command + + @_write_and_read_command.setter + def _write_and_read_command(self, value): + self.driver._write_and_read_command = value # type: ignore[method-assign] + + # -- HamiltonLiquidHandler abstract methods (delegate to driver) ----------- @property def module_id_length(self) -> int: @@ -400,6 +452,34 @@ def _parse_response(self, resp: str, fmt: Dict[str, str]) -> dict: """Parse a firmware response.""" return parse_vantage_fw_string(resp, fmt) + # -- send_command delegation ----------------------------------------------- + + async def send_command( + self, + module, + command, + auto_id=True, + tip_pattern=None, + write_timeout=None, + read_timeout=None, + wait=True, + fmt=None, + **kwargs, + ): + return await self.driver.send_command( + module=module, + command=command, + auto_id=auto_id, + tip_pattern=tip_pattern, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + fmt=fmt, + **kwargs, + ) + + # -- lifecycle (delegate to driver) ---------------------------------------- + async def setup( self, skip_loading_cover: bool = False, @@ -408,50 +488,26 @@ async def setup( ): """Creates a USB connection and finds read/write interfaces.""" - await super().setup() - - tip_presences = await self.query_tip_presence() - self._num_channels = len(tip_presences) - - arm_initialized = await self.arm_request_instrument_initialization_status() - if not arm_initialized: - await self.arm_pre_initialize() - - # TODO: check which modules are actually installed. + # Let the driver own the USB connection and perform hardware discovery. + await self.driver.setup( + skip_loading_cover=skip_loading_cover, + skip_core96=skip_core96, + skip_ipg=skip_ipg, + ) - pip_channels_initialized = await self.pip_request_initialization_status() - if not pip_channels_initialized or any(tip_presences): - await self.pip_initialize( - x_position=[7095] * self.num_channels, - y_position=[3891, 3623, 3355, 3087, 2819, 2551, 2283, 2016], - begin_z_deposit_position=[int(self._traversal_height * 10)] * self.num_channels, - end_z_deposit_position=[1235] * self.num_channels, - minimal_height_at_command_end=[int(self._traversal_height * 10)] * self.num_channels, - tip_pattern=[True] * self.num_channels, - tip_type=[1] * self.num_channels, - TODO_DI_2=70, - ) + # Sync legacy state from driver. + self.id_ = 0 + self._num_channels = self.driver.num_channels + self._traversal_height = self.driver.traversal_height + self._setup_done = True - loading_cover_initialized = await self.loading_cover_request_initialization_status() - if not loading_cover_initialized and not skip_loading_cover: - await self.loading_cover_initialize() - - core96_initialized = await self.core96_request_initialization_status() - if not core96_initialized and not skip_core96: - await self.core96_initialize( - x_position=7347, # TODO: get trash location from deck. - y_position=2684, # TODO: get trash location from deck. - minimal_traverse_height_at_begin_of_command=int(self._traversal_height * 10), - minimal_height_at_command_end=int(self._traversal_height * 10), - end_z_deposit_position=2420, - ) + async def stop(self): + await self.driver.stop() + self._setup_done = False - if not skip_ipg: - ipg_initialized = await self.ipg_request_initialization_status() - if not ipg_initialized: - await self.ipg_initialize() - if not await self.ipg_get_parking_status(): - await self.ipg_park() + @property + def setup_done(self) -> bool: + return self._setup_done @property def num_channels(self) -> int: From 649c5c3529bd2192d61d6ab15633253e53841d57 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Apr 2026 19:29:41 -0700 Subject: [PATCH 03/11] Remove duplicated error codes/parser from legacy VantageBackend Replace ~275 lines of inline definitions (error dicts, VantageFirmwareError, parse_vantage_fw_string, vantage_response_string_to_error) with re-exports from the new vantage modules. Existing imports continue to work. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backends/hamilton/vantage_backend.py | 287 +----------------- 1 file changed, 11 insertions(+), 276 deletions(-) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index a34968af2bb..dc446533a5c 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -1,6 +1,5 @@ import asyncio import random -import re import sys import warnings from typing import Dict, List, Optional, Sequence, Union, cast @@ -48,281 +47,17 @@ from typing_extensions import Literal -def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dict: - """Parse a Vantage firmware string into a dict. - - The identifier parameter (id) is added automatically. - - `fmt` is a dict that specifies the format of the string. The keys are the parameter names and the - values are the types. The following types are supported: - - - `"int"`: a single integer - - `"str"`: a string - - `"[int]"`: a list of integers - - `"hex"`: a hexadecimal number - - Example: - >>> parse_fw_string("id0xs30 -100 +1 1000", {"id": "int", "x": "[int]"}) - {"id": 0, "x": [30, -100, 1, 1000]} - - >>> parse_fw_string("es\"error string\"", {"es": "str"}) - {"es": "error string"} - """ - - parsed: dict = {} - - if fmt is None: - fmt = {} - - if not isinstance(fmt, dict): - raise TypeError(f"invalid fmt for fmt: expected dict, got {type(fmt)}") - - if "id" not in fmt: - fmt["id"] = "int" - - for key, data_type in fmt.items(): - if data_type == "int": - matches = re.findall(rf"{key}([-+]?\d+)", s) - if len(matches) != 1: - raise ValueError(f"Expected exactly one match for {key} in {s}") - parsed[key] = int(matches[0]) - elif data_type == "str": - matches = re.findall(rf"{key}\"(.*)\"", s) - if len(matches) != 1: - raise ValueError(f"Expected exactly one match for {key} in {s}") - parsed[key] = matches[0] - elif data_type == "[int]": - matches = re.findall(rf"{key}((?:[-+]?[\d ]+)+)", s) - if len(matches) != 1: - raise ValueError(f"Expected exactly one match for {key} in {s}") - parsed[key] = [int(x) for x in matches[0].split()] - elif data_type == "hex": - matches = re.findall(rf"{key}([0-9a-fA-F]+)", s) - if len(matches) != 1: - raise ValueError(f"Expected exactly one match for {key} in {s}") - parsed[key] = int(matches[0], 16) - else: - raise ValueError(f"Unknown data type {data_type}") - - return parsed - - -core96_errors = { - 0: "No error", - 21: "No communication to digital potentiometer", - 25: "Wrong Flash EPROM data", - 26: "Flash EPROM not programmable", - 27: "Flash EPROM not erasable", - 28: "Flash EPROM checksum error", - 29: "Wrong FW loaded", - 30: "Undefined command", - 31: "Undefined parameter", - 32: "Parameter out of range", - 35: "Voltages out of range", - 36: "Stop during command execution", - 37: "Adjustment sensor didn't switch (no teach in signal)", - 40: "No parallel processes on level 1 permitted", - 41: "No parallel processes on level 2 permitted", - 42: "No parallel processes on level 3 permitted", - 50: "Dispensing drive initialization failed", - 51: "Dispensing drive not initialized", - 52: "Dispensing drive movement error", - 53: "Maximum volume in tip reached", - 54: "Dispensing drive position out of permitted area", - 55: "Y drive initialization failed", - 56: "Y drive not initialized", - 57: "Y drive movement error", - 58: "Y drive position out of permitted area", - 60: "Z drive initialization failed", - 61: "Z drive not initialized", - 62: "Z drive movement error", - 63: "Z drive position out of permitted area", - 65: "Squeezer drive initialization failed", - 66: "Squeezer drive not initialized", - 67: "Squeezer drive movement error", - 68: "Squeezer drive position out of permitted area", - 70: "No liquid level found", - 71: "Not enough liquid present", - 75: "No tip picked up", - 76: "Tip already picked up", - 81: "Clot detected with LLD sensor", - 82: "TADM measurement out of lower limit curve", - 83: "TADM measurement out of upper limit curve", - 84: "Not enough memory for TADM measurement", - 90: "Limit curve not resettable", - 91: "Limit curve not programmable", - 92: "Limit curve name not found", - 93: "Limit curve data incorrect", - 94: "Not enough memory for limit curve", - 95: "Not allowed limit curve index", - 96: "Limit curve already stored", -} - -pip_errors = { - 22: "Drive controller message error", - 23: "EC drive controller setup not executed", - 25: "wrong Flash EPROM data", - 26: "Flash EPROM not programmable", - 27: "Flash EPROM not erasable", - 28: "Flash EPROM checksum error", - 29: "wrong FW loaded", - 30: "Undefined command", - 31: "Undefined parameter", - 32: "Parameter out of range", - 35: "Voltages out of range", - 36: "Stop during command execution", - 37: "Adjustment sensor didn't switch (no teach in signal)", - 38: "Movement interrupted by partner channel", - 39: "Angle alignment offset error", - 40: "No parallel processes on level 1 permitted", - 41: "No parallel processes on level 2 permitted", - 42: "No parallel processes on level 3 permitted", - 50: "D drive initialization failed", - 51: "D drive not initialized", - 52: "D drive movement error", - 53: "Maximum volume in tip reached", - 54: "D drive position out of permitted area", - 55: "Y drive initialization failed", - 56: "Y drive not initialized", - 57: "Y drive movement error", - 58: "Y drive position out of permitted area", - 59: "Divergance Y motion controller to linear encoder to height", - 60: "Z drive initialization failed", - 61: "Z drive not initialized", - 62: "Z drive movement error", - 63: "Z drive position out of permitted area", - 64: "Limit stop not found", - 65: "S drive initialization failed", - 66: "S drive not initialized", - 67: "S drive movement error", - 68: "S drive position out of permitted area", - 69: "Init. position adjustment error", - 70: "No liquid level found", - 71: "Not enough liquid present", - 74: "Liquid at a not allowed position detected", - 75: "No tip picked up", - 76: "Tip already picked up", - 77: "Tip not discarded", - 78: "Wrong tip detected", - 79: "Tip not correct squeezed", - 80: "Liquid not correctly aspirated", - 81: "Clot detected", - 82: "TADM measurement out of lower limit curve", - 83: "TADM measurement out of upper limit curve", - 84: "Not enough memory for TADM measurement", - 85: "Jet dispense pressure not reached", - 86: "ADC algorithm error", - 90: "Limit curve not resettable", - 91: "Limit curve not programmable", - 92: "Limit curve name not found", - 93: "Limit curve data incorrect", - 94: "Not enough memory for limit curve", - 95: "Not allowed limit curve index", - 96: "Limit curve already stored", -} - -ipg_errors = { - 0: "No error", - 22: "Drive controller message error", - 23: "EC drive controller setup not executed", - 25: "Wrong Flash EPROM data", - 26: "Flash EPROM not programmable", - 27: "Flash EPROM not erasable", - 28: "Flash EPROM checksum error", - 29: "Wrong FW loaded", - 30: "Undefined command", - 31: "Undefined parameter", - 32: "Parameter out of range", - 35: "Voltages out of range", - 36: "Stop during command execution", - 37: "Adjustment sensor didn't switch (no teach in signal)", - 39: "Angle alignment offset error", - 40: "No parallel processes on level 1 permitted", - 41: "No parallel processes on level 2 permitted", - 42: "No parallel processes on level 3 permitted", - 50: "Y Drive initialization failed", - 51: "Y Drive not initialized", - 52: "Y Drive movement error", - 53: "Y Drive position out of permitted area", - 54: "Diff. motion controller and lin. encoder counter too high", - 55: "Z Drive initialization failed", - 56: "Z Drive not initialized", - 57: "Z Drive movement error", - 58: "Z Drive position out of permitted area", - 59: "Z Drive limit stop not found", - 60: "Rotation Drive initialization failed", - 61: "Rotation Drive not initialized", - 62: "Rotation Drive movement error", - 63: "Rotation Drive position out of permitted area", - 65: "Wrist Twist Drive initialization failed", - 66: "Wrist Twist Drive not initialized", - 67: "Wrist Twist Drive movement error", - 68: "Wrist Twist Drive position out of permitted area", - 70: "Gripper Drive initialization failed", - 71: "Gripper Drive not initialized", - 72: "Gripper Drive movement error", - 73: "Gripper Drive position out of permitted area", - 80: "Plate not found", - 81: "Plate is still held", - 82: "No plate is held", -} - - -class VantageFirmwareError(Exception): - def __init__(self, errors, raw_response): - self.errors = errors - self.raw_response = raw_response - - def __str__(self): - return f"VantageFirmwareError(errors={self.errors}, raw_response={self.raw_response})" - - def __eq__(self, __value: object) -> bool: - return ( - isinstance(__value, VantageFirmwareError) - and self.errors == __value.errors - and self.raw_response == __value.raw_response - ) - - -def vantage_response_string_to_error( - string: str, -) -> VantageFirmwareError: - """Convert a Vantage firmware response string to a VantageFirmwareError. Assumes that the - response is an error response.""" - - try: - error_format = r"[A-Z0-9]{2}[0-9]{2}" - error_string = parse_vantage_fw_string(string, {"es": "str"})["es"] - error_codes = re.findall(error_format, error_string) - errors = {} - num_channels = 16 - for error in error_codes: - module, error_code = error[:2], error[2:] - error_code = int(error_code) - for channel in range(1, num_channels + 1): - if module == f"P{channel}": - errors[f"Pipetting channel {channel}"] = pip_errors.get(error_code, "Unknown error") - elif module in ("H0", "HM"): - errors["Core 96"] = core96_errors.get(error_code, "Unknown error") - elif module == "RM": - errors["IPG"] = ipg_errors.get(error_code, "Unknown error") - elif module == "AM": - errors["Cover"] = "Unknown error" - except ValueError: - module_id = string[:4] - module = modules = { - "I1AM": "Cover", - "C0AM": "Master", - "A1PM": "Pip", - "A1HM": "Core 96", - "A1RM": "IPG", - "A1AM": "Arm", - "A1XM": "X-arm", - }.get(module_id, "Unknown module") - error_string = parse_vantage_fw_string(string, {"et": "str"})["et"] - errors = {modules: error_string} - - return VantageFirmwareError(errors, string) +# Re-export from the new implementation. These used to be defined inline here. +from pylabrobot.hamilton.liquid_handlers.vantage.errors import ( # noqa: F401 + VantageFirmwareError, + core96_errors, + ipg_errors, + pip_errors, + vantage_response_string_to_error, +) +from pylabrobot.hamilton.liquid_handlers.vantage.fw_parsing import ( # noqa: F401 + parse_vantage_fw_string, +) def _get_dispense_mode(jet: bool, empty: bool, blow_out: bool) -> Literal[0, 1, 2, 3, 4]: From 648d8161bdce332b78d0f688e73a476e13815623 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 12:19:19 -0700 Subject: [PATCH 04/11] Complete Vantage port: standard units, full delegation, subsystem ownership - All new backend methods use standard PLR units (mm, uL, uL/s, s) with firmware conversion (*10, *100) at the send_command boundary - Legacy VantageBackend fully delegated: every method forwards to new backends with unit conversion, zero methods still use send_command directly - Subsystems own their setup: each _on_setup() checks init status and initializes if needed (moved out of driver.setup()) - Added VantageCoreGripper backend (A1PM:DG/DR/DH/DO/DJ) - IPG rewritten: public firmware methods (grip_plate, put_plate, etc.), press_on_distance properly threaded through, halt/is_gripper_closed raise NotImplementedError - X-arm methods renamed to match STAR (move_to, move_to_safe) - disco_mode and russian_roulette moved to VantageDriver - Test files renamed to *_tests.py to match pytest.ini - FIXME comments on pre-existing bugs (er0 substring, channel 10-16 parsing, hardcoded 8 y-positions) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/vantage/chatterbox.py | 48 +- .../hamilton/liquid_handlers/vantage/core.py | 349 ++ .../liquid_handlers/vantage/driver.py | 277 +- .../liquid_handlers/vantage/errors.py | 58 +- .../liquid_handlers/vantage/head96_backend.py | 746 ++- .../hamilton/liquid_handlers/vantage/ipg.py | 315 +- .../liquid_handlers/vantage/loading_cover.py | 15 +- .../liquid_handlers/vantage/pip_backend.py | 1719 +++++- .../tests/{test_errors.py => errors_tests.py} | 0 ...test_fw_parsing.py => fw_parsing_tests.py} | 0 .../liquid_handlers/vantage/vantage.py | 43 +- .../hamilton/liquid_handlers/vantage/x_arm.py | 133 +- .../backends/hamilton/vantage_backend.py | 4663 ++++------------- .../backends/hamilton/vantage_tests.py | 23 +- 14 files changed, 4176 insertions(+), 4213 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/vantage/core.py rename pylabrobot/hamilton/liquid_handlers/vantage/tests/{test_errors.py => errors_tests.py} (100%) rename pylabrobot/hamilton/liquid_handlers/vantage/tests/{test_fw_parsing.py => fw_parsing_tests.py} (100%) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py index c0b6d30cbda..c99319aa2bf 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py @@ -11,9 +11,26 @@ class VantageChatterboxDriver(VantageDriver): - """A VantageDriver that prints firmware commands instead of communicating with hardware. + """A VantageDriver that logs firmware commands instead of communicating with hardware. - Useful for testing, debugging, and development without a physical Vantage. + This mock driver is used for testing, debugging, and development without a physical + Hamilton Vantage instrument. All firmware commands are logged at INFO level via the + ``pylabrobot`` logger instead of being sent over USB. + + The chatterbox driver: + + - Skips USB connection and hardware discovery entirely. + - Assumes 8 PIP channels. + - Creates all subsystem backends (PIP, Head96, IPG, X-arm, loading cover) with no + firmware initialization. + - Returns None from all :meth:`send_command` and :meth:`send_raw_command` calls. + + Usage:: + + from pylabrobot.hamilton.liquid_handlers.vantage import Vantage + + vantage = Vantage(deck=deck, chatterbox=True) + await vantage.setup() # no hardware needed """ def __init__(self): @@ -25,6 +42,11 @@ async def setup( skip_core96: bool = False, skip_ipg: bool = False, ): + """Set up the chatterbox driver without any hardware communication. + + Creates all subsystem backends in mock mode. See :meth:`VantageDriver.setup` + for the ``skip_*`` parameters. + """ # Skip USB and hardware discovery entirely. # Import backends here to avoid circular imports. from .head96_backend import VantageHead96Backend @@ -42,19 +64,25 @@ async def setup( if self.ipg is not None: self.ipg._parked = True self.x_arm = VantageXArm(driver=self) - self.loading_cover = VantageLoadingCover(driver=self) + self.loading_cover = VantageLoadingCover(driver=self) if not skip_loading_cover else None - # Initialize subsystems. - for sub in self._subsystems: - await sub._on_setup() + # Skip _on_setup() on subsystems: send_command returns None in chatterbox mode, + # which would cause status-query methods (e.g. query_tip_presence) to crash. + # Subsystems are already in a usable state since all firmware commands are no-ops. async def stop(self): + """Stop the chatterbox driver and clear subsystem state. + + Calls ``_on_stop()`` on all subsystems (no-ops in chatterbox mode) and clears + internal state. Does not call ``super().stop()`` since there is no USB connection. + """ # Stop subsystems (no-ops for chatterbox, but follows the pattern). for sub in reversed(self._subsystems): await sub._on_stop() # Clear state (skip super().stop() since there is no USB to close). self._num_channels = None self._tth2tti.clear() + self.pip = None self.head96 = None self.ipg = None self.x_arm = None @@ -72,6 +100,10 @@ async def send_command( fmt: Optional[Any] = None, **kwargs, ): + """Assemble a firmware command string and log it instead of sending over USB. + + Returns None (no firmware response in chatterbox mode). + """ cmd, _ = self._assemble_command( module=module, command=command, @@ -89,5 +121,9 @@ async def send_raw_command( read_timeout: Optional[int] = None, wait: bool = True, ) -> Optional[str]: + """Log a raw firmware command string instead of sending over USB. + + Returns None (no firmware response in chatterbox mode). + """ logger.info("Chatterbox raw: %s", command) return None diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/core.py b/pylabrobot/hamilton/liquid_handlers/vantage/core.py new file mode 100644 index 00000000000..a4594d99dac --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/core.py @@ -0,0 +1,349 @@ +"""Vantage CoreGripper: CoRe gripper backend for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.capabilities.arms.backend import GripperArmBackend +from pylabrobot.capabilities.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from .driver import VantageDriver + + +class VantageCoreGripper(GripperArmBackend): + """Backend for the Vantage CoRe gripper tools. + + CoRe grippers are tools that mount on two PIP channels and grip plates along the + Y-axis. Unlike the IPG (which is a dedicated arm), the CoRe gripper shares the PIP + channels' X/Y/Z drives. + + Firmware commands use module ``A1PM`` with commands ``DG`` (grip), ``DR`` (put), + ``DH`` (move), ``DO`` (release), ``DJ`` (discard tool). + """ + + def __init__(self, driver: "VantageDriver"): + self.driver = driver + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- BackendParams --------------------------------------------------------- + + @dataclass + class PickUpParams(BackendParams): + """Vantage-specific parameters for CoRe gripper plate pickup. + + Args: + grip_strength: Grip strength (0 = low, 99 = high). Default 30. + z_speed: Z speed in mm/s. Default 128.7. + open_gripper_position: Open gripper position in mm. Default 86.0. + acceleration_index: Acceleration index (0-4). Default 4. + minimal_traverse_height_at_begin_of_command: Minimal traverse height in mm. + If None, uses driver's traversal_height. + minimal_height_at_command_end: Minimal height at command end in mm. + If None, uses driver's traversal_height. + """ + + grip_strength: int = 30 + z_speed: float = 128.7 + open_gripper_position: float = 86.0 + acceleration_index: int = 4 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + + @dataclass + class DropParams(BackendParams): + """Vantage-specific parameters for CoRe gripper plate drop. + + Args: + press_on_distance: Press-on distance in mm. Default 0.5. + z_speed: Z speed in mm/s. Default 128.7. + open_gripper_position: Open gripper position in mm. Default 86.0. + minimal_traverse_height_at_begin_of_command: Minimal traverse height in mm. + If None, uses driver's traversal_height. + minimal_height_at_command_end: Minimal height at command end in mm. + If None, uses driver's traversal_height. + """ + + press_on_distance: float = 0.5 + z_speed: float = 128.7 + open_gripper_position: float = 86.0 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + minimal_height_at_command_end: Optional[float] = None + + @dataclass + class MoveParams(BackendParams): + """Vantage-specific parameters for CoRe gripper move. + + Args: + z_speed: Z speed in mm/s. Default 128.7. + minimal_traverse_height_at_begin_of_command: Minimal traverse height in mm. + If None, uses driver's traversal_height. + """ + + z_speed: float = 128.7 + minimal_traverse_height_at_begin_of_command: Optional[float] = None + + # -- GripperArmBackend interface ------------------------------------------- + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Grip a plate at the given location. + + Args: + location: Absolute (x, y, z) position of the plate center in mm. + resource_width: Width of the resource to grip in mm. + backend_params: Optional :class:`VantageCoreGripper.PickUpParams`. + """ + if not isinstance(backend_params, VantageCoreGripper.PickUpParams): + backend_params = VantageCoreGripper.PickUpParams() + + th = self.driver.traversal_height + open_pos = resource_width + 3.2 + plate_w = resource_width - 3.3 + + await self._grip_plate( + x_position=location.x, + y_position=location.y, + z_position=location.z, + z_speed=backend_params.z_speed, + open_gripper_position=open_pos, + plate_width=plate_w, + acceleration_index=backend_params.acceleration_index, + grip_strength=backend_params.grip_strength, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th + ), + minimal_height_at_command_end=backend_params.minimal_height_at_command_end or th, + ) + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Place a plate at the given location. + + Args: + location: Absolute (x, y, z) position to place the plate in mm. + resource_width: Width of the resource being placed in mm. + backend_params: Optional :class:`VantageCoreGripper.DropParams`. + """ + if not isinstance(backend_params, VantageCoreGripper.DropParams): + backend_params = VantageCoreGripper.DropParams() + + th = self.driver.traversal_height + open_pos = resource_width + 3.2 + + await self._put_plate( + x_position=location.x, + y_position=location.y, + z_position=location.z, + press_on_distance=backend_params.press_on_distance, + z_speed=backend_params.z_speed, + open_gripper_position=open_pos, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th + ), + minimal_height_at_command_end=backend_params.minimal_height_at_command_end or th, + ) + + async def move_to_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move held plate to a new position. + + Args: + location: Absolute (x, y, z) target position in mm. + backend_params: Optional :class:`VantageCoreGripper.MoveParams`. + """ + if not isinstance(backend_params, VantageCoreGripper.MoveParams): + backend_params = VantageCoreGripper.MoveParams() + + th = self.driver.traversal_height + + await self._move_to_position( + x_position=location.x, + y_position=location.y, + z_position=location.z, + z_speed=backend_params.z_speed, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th + ), + ) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Release the gripped object (A1PM:DO).""" + await self.driver.send_command(module="A1PM", command="DO", pa=1) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + pass # Grip happens in pick_up_at_location. + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + raise NotImplementedError() + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + pass + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + pass # Tool management (pick up / return) is handled by the Vantage device class. + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + raise NotImplementedError("request_gripper_location is not implemented for VantageCoreGripper.") + + # -- Firmware commands (A1PM) ---------------------------------------------- + + async def _grip_plate( + self, + x_position: float, + y_position: float, + z_position: float, + z_speed: float = 128.7, + open_gripper_position: float = 86.0, + plate_width: float = 80.0, + acceleration_index: int = 4, + grip_strength: int = 30, + minimal_traverse_height_at_begin_of_command: float = 360.0, + minimal_height_at_command_end: float = 360.0, + ): + """Grip plate using CoRe grippers (A1PM:DG). + + Args: + x_position: Plate center X position [mm]. + y_position: Plate center Y position [mm]. + z_position: Plate center Z position [mm]. + z_speed: Z speed [mm/s]. + open_gripper_position: Open gripper position [mm]. + plate_width: Plate width [mm]. + acceleration_index: Acceleration index (0-4). + grip_strength: Grip strength (0-99). + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + minimal_height_at_command_end: Minimal height at command end [mm]. + """ + await self.driver.send_command( + module="A1PM", + command="DG", + xa=round(x_position * 10), + yj=round(y_position * 10), + zj=round(z_position * 10), + zy=round(z_speed * 10), + yo=round(open_gripper_position * 10), + yg=round(plate_width * 10), + ai=acceleration_index, + yw=grip_strength, + th=[round(minimal_traverse_height_at_begin_of_command * 10)] * self.driver.num_channels, + te=[round(minimal_height_at_command_end * 10)] * self.driver.num_channels, + ) + + async def _put_plate( + self, + x_position: float, + y_position: float, + z_position: float, + press_on_distance: float = 0.5, + z_speed: float = 128.7, + open_gripper_position: float = 86.0, + minimal_traverse_height_at_begin_of_command: float = 360.0, + minimal_height_at_command_end: float = 360.0, + ): + """Put plate using CoRe grippers (A1PM:DR). + + Args: + x_position: Plate center X position [mm]. + y_position: Plate center Y position [mm]. + z_position: Plate center Z position [mm]. + press_on_distance: Press-on distance [mm]. + z_speed: Z speed [mm/s]. + open_gripper_position: Open gripper position [mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + minimal_height_at_command_end: Minimal height at command end [mm]. + """ + await self.driver.send_command( + module="A1PM", + command="DR", + xa=round(x_position * 10), + yj=round(y_position * 10), + zj=round(z_position * 10), + zi=round(press_on_distance * 10), + zy=round(z_speed * 10), + yo=round(open_gripper_position * 10), + th=[round(minimal_traverse_height_at_begin_of_command * 10)] * self.driver.num_channels, + te=[round(minimal_height_at_command_end * 10)] * self.driver.num_channels, + ) + + async def _move_to_position( + self, + x_position: float, + y_position: float, + z_position: float, + z_speed: float = 128.7, + minimal_traverse_height_at_begin_of_command: float = 360.0, + ): + """Move to position with CoRe grippers (A1PM:DH). + + Args: + x_position: Plate center X position [mm]. + y_position: Plate center Y position [mm]. + z_position: Plate center Z position [mm]. + z_speed: Z speed [mm/s]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + """ + await self.driver.send_command( + module="A1PM", + command="DH", + xa=round(x_position * 10), + yj=round(y_position * 10), + zj=round(z_position * 10), + zy=round(z_speed * 10), + th=[round(minimal_traverse_height_at_begin_of_command * 10)] * self.driver.num_channels, + ) + + async def discard_tool( + self, + x_position: float = 0.0, + first_gripper_tool_y_pos: float = 300.0, + first_pip_channel_node_no: int = 1, + minimal_traverse_height_at_begin_of_command: float = 360.0, + minimal_height_at_command_end: float = 360.0, + ): + """Discard CoRe gripper tool (A1PM:DJ). + + Args: + x_position: Gripper tool X position [mm]. + first_gripper_tool_y_pos: First (lower channel) CoRe gripper tool Y position [mm]. + first_pip_channel_node_no: First (lower) pip channel node number (1-16). + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + minimal_height_at_command_end: Minimal height at command end [mm]. + """ + await self.driver.send_command( + module="A1PM", + command="DJ", + xa=round(x_position * 10), + yj=round(first_gripper_tool_y_pos * 10), + tt=[4] * self.driver.num_channels, + tp=[0] * self.driver.num_channels, + tz=[0] * self.driver.num_channels, + th=[round(minimal_traverse_height_at_begin_of_command * 10)] * self.driver.num_channels, + pa=first_pip_channel_node_no, + te=[round(minimal_height_at_command_end * 10)] * self.driver.num_channels, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py index 0195b5dcef6..63375f21062 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py @@ -1,5 +1,8 @@ """VantageDriver: inherits HamiltonLiquidHandler, adds Vantage-specific config and error handling.""" +import asyncio +import random + from typing import TYPE_CHECKING, Any, List, Literal, Optional, Union from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend @@ -19,8 +22,23 @@ class VantageDriver(HamiltonLiquidHandler): """Driver for Hamilton Vantage liquid handlers. - Inherits USB I/O, command assembly, and background reading from HamiltonLiquidHandler. + Inherits USB I/O, command assembly, and background reading from + :class:`~pylabrobot.hamilton.liquid_handlers.base.HamiltonLiquidHandler`. Adds Vantage-specific firmware parsing, error handling, and subsystem management. + + The Vantage uses USB product ID ``0x8003`` and a 4-character module ID length in + its firmware protocol (compared to 2 characters on the STAR). + + **Setup flow** (see :meth:`setup`): + + 1. Open USB connection (inherited from HamiltonLiquidHandler). + 2. Discover channel count by querying tip presence. + 3. Pre-initialize the arm module (A1AM). + 4. Create and initialize PIP, X-arm, and loading cover subsystems. + 5. Optionally initialize the Core 96-head (A1HM) and IPG (A1RM) if present. + + After setup completes, subsystem backends are available as ``self.pip``, + ``self.head96``, ``self.ipg``, ``self.x_arm``, and ``self.loading_cover``. """ def __init__( @@ -31,6 +49,16 @@ def __init__( read_timeout: int = 60, write_timeout: int = 30, ): + """Initialize the VantageDriver. + + Args: + device_address: USB device address. If None, auto-detected. + serial_number: USB serial number filter. If None, connects to the first + matching device. + packet_read_timeout: Timeout in seconds for reading individual USB packets. + read_timeout: Timeout in seconds for reading a complete firmware response. + write_timeout: Timeout in seconds for writing a firmware command. + """ super().__init__( id_product=0x8003, device_address=device_address, @@ -54,25 +82,63 @@ def __init__( @property def module_id_length(self) -> int: + """Length of the module identifier prefix in firmware messages. + + The Vantage uses 4-character module IDs (e.g. ``A1PM``, ``A1HM``, ``A1RM``), + compared to the STAR's 2-character IDs (e.g. ``C0``, ``R0``). + """ return 4 @property def num_channels(self) -> int: + """Number of PIP channels discovered during setup. + + Raises: + RuntimeError: If the driver has not been set up yet. + """ if self._num_channels is None: raise RuntimeError("Driver not set up - call setup() first.") return self._num_channels def get_id_from_fw_response(self, resp: str) -> Optional[int]: + """Extract the command ID from a Vantage firmware response string. + + Args: + resp: Raw firmware response string. + + Returns: + The integer command ID, or None if the response does not contain one. + """ parsed = parse_vantage_fw_string(resp, {"id": "int"}) if "id" in parsed and parsed["id"] is not None: return int(parsed["id"]) return None def check_fw_string_error(self, resp: str) -> None: + """Check a firmware response string for errors and raise if found. + + Args: + resp: Raw firmware response string. + + Raises: + VantageFirmwareError: If the response contains a non-zero error code. + """ + # FIXME: "er0" substring check also suppresses er01-er09 (error codes 1-9). + # Pre-existing bug from legacy. Needs proper regex-based error detection. if "er" in resp and "er0" not in resp: raise vantage_response_string_to_error(resp) def _parse_response(self, resp: str, fmt: Any) -> dict: + """Parse a Vantage firmware response string using the given format specification. + + Args: + resp: Raw firmware response string. + fmt: Format dictionary mapping parameter names to type strings + (e.g. ``{"qw": "int"}``). + + Returns: + Dictionary of parsed key-value pairs. + """ return parse_vantage_fw_string(resp, fmt) async def define_tip_needle( @@ -84,6 +150,19 @@ async def define_tip_needle( tip_size: TipSize, pickup_method: TipPickupMethod, ) -> None: + """Define a tip/needle type in the firmware tip table (A1AM:TT). + + Values set here are temporary and apply only until power OFF or RESET. + + Args: + tip_type_table_index: Index in the tip table (0-99). + has_filter: Whether the tip has a filter. + tip_length: Tip length [0.1mm] (1-1999). + maximum_tip_volume: Maximum volume of tip [0.1ul] (1-56000). Automatically limited to + max channel capacity. + tip_size: Type of tip collar (tip type identification). + pickup_method: Tip pick-up method. + """ if not 0 <= tip_type_table_index <= 99: raise ValueError("tip_type_table_index must be between 0 and 99") if not 1 <= tip_length <= 1999: @@ -111,6 +190,12 @@ def set_minimum_traversal_height(self, traversal_height: float) -> None: @property def traversal_height(self) -> float: + """Current minimum traversal height in mm. + + This value is used as the default Z-safety height for all subsystem backends + (PIP, Head96, IPG) when their ``BackendParams`` leave the traverse height as None. + Default is 245.0mm. Can be changed via :meth:`set_minimum_traversal_height`. + """ return self._traversal_height # -- lifecycle ------------------------------------------------------------- @@ -121,6 +206,17 @@ async def setup( skip_core96: bool = False, skip_ipg: bool = False, ): + """Initialize the Vantage hardware and all subsystem backends. + + This method opens the USB connection, discovers the channel count, and + initializes subsystems (PIP, loading cover, Core 96-head, IPG, X-arm). + Subsystems can be skipped with the ``skip_*`` flags. + + Args: + skip_loading_cover: If True, skip loading cover initialization. + skip_core96: If True, skip Core 96-head initialization. + skip_ipg: If True, skip IPG (Integrated Plate Gripper) initialization. + """ await super().setup() self.id_ = 0 @@ -135,63 +231,19 @@ async def setup( tip_presences = await self.query_tip_presence() self._num_channels = len(tip_presences) - # Arm pre-initialization. + # Arm pre-initialization (device-level, not subsystem-specific). arm_initialized = await self.arm_request_instrument_initialization_status() if not arm_initialized: await self.arm_pre_initialize() - # Create backends. + # Create subsystem instances. self.pip = VantagePIPBackend(self) self.x_arm = VantageXArm(driver=self) - self.loading_cover = VantageLoadingCover(driver=self) - - # Initialize PIP channels. - pip_channels_initialized = await self.pip_request_initialization_status() - if not pip_channels_initialized or any(tip_presences): - await self.pip_initialize( - x_position=[7095] * self.num_channels, - y_position=[3891, 3623, 3355, 3087, 2819, 2551, 2283, 2016], - begin_z_deposit_position=[int(self._traversal_height * 10)] * self.num_channels, - end_z_deposit_position=[1235] * self.num_channels, - minimal_height_at_command_end=[int(self._traversal_height * 10)] * self.num_channels, - tip_pattern=[True] * self.num_channels, - tip_type=[1] * self.num_channels, - ts=70, - ) - - # Loading cover. - if not skip_loading_cover: - loading_cover_initialized = await self.loading_cover.request_initialization_status() - if not loading_cover_initialized: - await self.loading_cover.initialize() - - # Core 96 head. - core96_initialized = await self.core96_request_initialization_status() - if not core96_initialized and not skip_core96: - self.head96 = VantageHead96Backend(self) - await self.core96_initialize( - x_position=7347, - y_position=2684, - minimal_traverse_height_at_begin_of_command=int(self._traversal_height * 10), - minimal_height_at_command_end=int(self._traversal_height * 10), - end_z_deposit_position=2420, - ) - else: - # Even if already initialized, create the backend. - self.head96 = VantageHead96Backend(self) if not skip_core96 else None - - # IPG. - if not skip_ipg: - self.ipg = IPGBackend(driver=self) - ipg_initialized = await self.ipg.request_initialization_status() - if not ipg_initialized: - await self.ipg.initialize() - if not await self.ipg.get_parking_status(): - await self.ipg.park() - else: - self.ipg = None + self.loading_cover = VantageLoadingCover(driver=self) if not skip_loading_cover else None + self.head96 = VantageHead96Backend(self) if not skip_core96 else None + self.ipg = IPGBackend(driver=self) if not skip_ipg else None - # Initialize subsystems. + # Each subsystem's _on_setup() handles its own initialization check. for sub in self._subsystems: await sub._on_setup() @@ -217,6 +269,7 @@ async def stop(self): await sub._on_stop() await super().stop() self._num_channels = None + self.pip = None self.head96 = None self.ipg = None self.x_arm = None @@ -242,23 +295,34 @@ async def pip_request_initialization_status(self) -> bool: async def pip_initialize( self, - x_position: List[int], - y_position: List[int], - begin_z_deposit_position: Optional[List[int]] = None, - end_z_deposit_position: Optional[List[int]] = None, - minimal_height_at_command_end: Optional[List[int]] = None, + x_position: List[float], + y_position: List[float], + begin_z_deposit_position: Optional[List[float]] = None, + end_z_deposit_position: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, tip_pattern: Optional[List[bool]] = None, tip_type: Optional[List[int]] = None, - ts: int = 0, + TODO_DI_2: int = 0, ) -> None: - """Initialize PIP channels (A1PM:DI).""" + """Initialize PIP channels (A1PM:DI). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + begin_z_deposit_position: Begin of tip deposit process (Z-discard range) [mm]. + end_z_deposit_position: Z deposit position [mm] (collar bearing position). + minimal_height_at_command_end: Minimal height at command end [mm]. + tip_pattern: Tip pattern (channels involved). False = not involved, True = involved. + tip_type: Tip type (see command TT / define_tip_needle). + TODO_DI_2: Unknown firmware parameter (maps to firmware key ``ts``). + """ if begin_z_deposit_position is None: - begin_z_deposit_position = [0] * self.num_channels + begin_z_deposit_position = [0.0] * self.num_channels if end_z_deposit_position is None: - end_z_deposit_position = [0] * self.num_channels + end_z_deposit_position = [0.0] * self.num_channels if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels + minimal_height_at_command_end = [360.0] * self.num_channels if tip_pattern is None: tip_pattern = [False] * self.num_channels if tip_type is None: @@ -267,19 +331,21 @@ async def pip_initialize( await self.send_command( module="A1PM", command="DI", - xp=x_position, - yp=y_position, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - te=minimal_height_at_command_end, + xp=[round(v * 10) for v in x_position], + yp=[round(v * 10) for v in y_position], + tp=[round(v * 10) for v in begin_z_deposit_position], + tz=[round(v * 10) for v in end_z_deposit_position], + te=[round(v * 10) for v in minimal_height_at_command_end], tm=tip_pattern, tt=tip_type, - ts=ts, + ts=TODO_DI_2, ) async def query_tip_presence(self) -> List[bool]: """Query tip presence on all channels (A1PM:QA).""" resp = await self.send_command(module="A1PM", command="QA", fmt={"rt": "[int]"}) + if resp is None: + return [False] * (self._num_channels or 8) presences_int: List[int] = resp["rt"] return [bool(p) for p in presences_int] @@ -292,24 +358,35 @@ async def core96_request_initialization_status(self) -> bool: async def core96_initialize( self, - x_position: int = 7347, - y_position: int = 2684, - z_position: int = 0, - minimal_traverse_height_at_begin_of_command: int = 2450, - minimal_height_at_command_end: int = 2450, - end_z_deposit_position: int = 2420, + x_position: float = 734.7, + y_position: float = 268.4, + z_position: float = 0.0, + minimal_traverse_height_at_begin_of_command: float = 245.0, + minimal_height_at_command_end: float = 245.0, + end_z_deposit_position: float = 242.0, tip_type: int = 4, ) -> None: - """Initialize Core 96 head (A1HM:DI).""" + """Initialize the Core 96 head (A1HM:DI). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + z_position: Z position [mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [mm]. + minimal_height_at_command_end: Minimal height at command end [mm]. + end_z_deposit_position: Z deposit position [mm] (collar bearing position). + tip_type: Tip type (see command TT / define_tip_needle). + """ await self.send_command( module="A1HM", command="DI", - xp=x_position, - yp=y_position, - zp=z_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - tz=end_z_deposit_position, + xp=round(x_position * 10), + yp=round(y_position * 10), + zp=round(z_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + te=round(minimal_height_at_command_end * 10), + tz=round(end_z_deposit_position * 10), tt=tip_type, ) @@ -326,7 +403,18 @@ async def set_led_color( uv: int, blink_interval: Optional[int] = None, ) -> None: - """Set the instrument LED color (C0AM:LI).""" + """Set the instrument LED color (C0AM:LI). + + Args: + mode: LED mode. One of "on", "off", or "blink". + intensity: LED intensity (0-100). + white: White LED value (0-100). + red: Red LED value (0-100). + green: Green LED value (0-100). + blue: Blue LED value (0-100). + uv: UV LED value (0-100). + blink_interval: Blink interval in ms. Only used when mode is "blink". + """ if blink_interval is not None and mode != "blink": raise ValueError("blink_interval is only used when mode is 'blink'.") @@ -338,3 +426,30 @@ async def set_led_color( ok=blink_interval or 750, ol=f"{white} {red} {green} {blue} {uv}", ) + + async def disco_mode(self): + """Easter egg.""" + for _ in range(69): + r, g, b = random.randint(30, 100), random.randint(30, 100), random.randint(30, 100) + await self.set_led_color("on", intensity=100, white=0, red=r, green=g, blue=b, uv=0) + await asyncio.sleep(0.1) + + async def russian_roulette(self): + """Dangerous easter egg.""" + sure = input( + "Are you sure you want to play Russian Roulette? This will turn on the uv-light " + "with a probability of 1/6. (yes/no) " + ) + if sure.lower() != "yes": + print("boring") + return + + if random.randint(1, 6) == 6: + await self.set_led_color("on", intensity=100, white=100, red=100, green=0, blue=0, uv=100) + print("You lost.") + else: + await self.set_led_color("on", intensity=100, white=100, red=0, green=100, blue=0, uv=0) + print("You won.") + + await asyncio.sleep(5) + await self.set_led_color("on", intensity=100, white=100, red=100, green=100, blue=100, uv=0) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/errors.py b/pylabrobot/hamilton/liquid_handlers/vantage/errors.py index 9100195851d..e1d2ec31d9a 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/errors.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/errors.py @@ -10,6 +10,10 @@ # --------------------------------------------------------------------------- # Error dictionaries (per-module error code -> human-readable message) +# +# These dictionaries map firmware integer error codes to human-readable messages +# for each Vantage subsystem module. They are used by +# ``vantage_response_string_to_error`` to produce meaningful error descriptions. # --------------------------------------------------------------------------- core96_errors: Dict[int, str] = { @@ -186,6 +190,11 @@ "A1AM": "Arm", "A1XM": "X-arm", } +"""Mapping from Vantage 4-character firmware module IDs to human-readable names. + +Used for error reporting and diagnostics. Each key is the module prefix that appears +at the start of firmware response strings (e.g. ``A1PM`` for the pipetting module). +""" # --------------------------------------------------------------------------- @@ -194,7 +203,13 @@ class VantageFirmwareError(Exception): - """Error raised when the Vantage firmware returns an error response.""" + """Error raised when the Vantage firmware returns an error response. + + Attributes: + errors: Dictionary mapping subsystem names (e.g. ``"Pipetting channel 1"``, + ``"Core 96"``, ``"IPG"``) to human-readable error messages. + raw_response: The original firmware response string that triggered the error. + """ def __init__(self, errors: Dict[str, str], raw_response: str): self.errors = errors @@ -217,12 +232,29 @@ def __eq__(self, other: object) -> bool: def vantage_response_string_to_error(string: str) -> VantageFirmwareError: - """Convert a Vantage firmware response string to a VantageFirmwareError. + """Parse a Vantage firmware error response string into a VantageFirmwareError. + + Extracts per-module error codes from the ``es`` (error string) field of the firmware + response, maps them to human-readable messages using the module-specific error + dictionaries (``pip_errors``, ``core96_errors``, ``ipg_errors``), and returns a + structured :class:`VantageFirmwareError`. + + The firmware error string contains pairs of module ID + error code (e.g. ``"P170"`` + means pipetting channel 1, error code 70 = "No liquid level found"). Multiple errors + may be present in a single response. + + If the ``es`` field cannot be parsed, falls back to the ``et`` (error text) field. - Assumes that the response is an error response. + Args: + string: Raw firmware response string containing an error. + + Returns: + A :class:`VantageFirmwareError` with parsed error details. """ try: + # FIXME: regex [A-Z0-9]{2}[0-9]{2} only captures 2-char module IDs, so channels + # 10-16 (P10, P11, ...) can never match. Pre-existing bug from legacy. error_format = r"[A-Z0-9]{2}[0-9]{2}" error_string = parse_vantage_fw_string(string, {"es": "str"})["es"] error_codes = re.findall(error_format, error_string) @@ -257,9 +289,25 @@ def vantage_response_string_to_error(string: str) -> VantageFirmwareError: def convert_vantage_firmware_error_to_plr_error( error: VantageFirmwareError, ) -> Optional[Exception]: - """Convert a VantageFirmwareError to a standard PLR error if possible. + """Convert a VantageFirmwareError to a standard PyLabRobot error if possible. + + Checks whether all errors in the :class:`VantageFirmwareError` are pipetting channel + errors, and if so, maps each to the appropriate PLR exception type: + + - Error 76 ("Tip already picked up") -> :class:`HasTipError` + - Error 75 ("No tip picked up") -> :class:`NoTipError` + - Error 70/71 ("No liquid level found" / "Not enough liquid present") -> + :class:`TooLittleLiquidError` + - All other errors -> generic :class:`Exception` + + The result is wrapped in a :class:`ChannelizedError` with per-channel error details. + + Args: + error: The Vantage firmware error to convert. - Returns the converted error, or None if no conversion is applicable. + Returns: + A :class:`ChannelizedError` if all errors are pipetting channel errors, or None + if the error involves non-pipetting modules (Core 96, IPG, etc.). """ # If all errors are pipetting channel errors, return a ChannelizedError. diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py index 0652a2c4122..519cc15c2a5 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -32,7 +32,22 @@ def _channel_pattern_to_hex(pattern: List[bool]) -> str: - """Convert a list of 96 booleans to the hex string expected by firmware.""" + """Convert a list of 96 booleans to the hex string expected by the Vantage firmware. + + The Vantage 96-head firmware commands accept a channel mask as a hexadecimal string. + Each boolean in the list represents one channel of the 96-head (A1 through H12). + The list is reversed to match firmware bit ordering (LSB first), then converted + to a hex string with the leading '0x' prefix stripped. + + Args: + pattern: List of exactly 96 booleans. True = channel active, False = channel inactive. + + Returns: + Uppercase hexadecimal string (e.g. ``"FFFFFFFFFFFFFFFFFFFFFFFF"`` for all 96 active). + + Raises: + ValueError: If the list does not contain exactly 96 elements. + """ if len(pattern) != 96: raise ValueError("channel_pattern must be a list of 96 boolean values") channel_pattern_bin_str = reversed(["1" if x else "0" for x in pattern]) @@ -40,13 +55,35 @@ def _channel_pattern_to_hex(pattern: List[bool]) -> str: class VantageHead96Backend(Head96Backend): - """Translates Head96 operations into Vantage firmware commands via the driver.""" + """Translates Head96 operations into Vantage firmware commands via the driver. + + This backend implements the ``Head96Backend`` interface for the Hamilton Vantage. + It converts high-level 96-head operations (``pick_up_tips96``, ``drop_tips96``, + ``aspirate96``, ``dispense96``) into low-level firmware commands on the A1HM module, + handling coordinate conversion, liquid class resolution, volume correction, and + Z-height computation. + + Each public method accepts an optional ``BackendParams`` dataclass that exposes + Vantage-specific parameters. When these parameters are None, sensible defaults + are computed from resource geometry, liquid classes, and the driver's + ``traversal_height``. + """ def __init__(self, driver: "VantageDriver"): self.driver = driver async def _on_setup(self): - pass + """Check Core96 initialization status and initialize if needed.""" + core96_initialized = await self.driver.core96_request_initialization_status() + if not core96_initialized: + th = self.driver.traversal_height + await self.driver.core96_initialize( + x_position=734.7, + y_position=268.4, + minimal_traverse_height_at_begin_of_command=th, + minimal_height_at_command_end=th, + end_z_deposit_position=242.0, + ) async def _on_stop(self): pass @@ -55,6 +92,20 @@ async def _on_stop(self): @dataclass class PickUpTipsParams(BackendParams): + """Vantage-specific parameters for ``pick_up_tips96``. + + Args: + tip_handling_method: Tip handling method code (0 = normal, 1 = side touch). + Default 0. + z_deposit_position: Z deposit position in mm (collar bearing position) for the + 96-head. Default 216.4. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins. If None, uses the driver's ``traversal_height``. + Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command. + If None, uses the driver's ``traversal_height``. Must be between 0 and 360.0. + """ + tip_handling_method: int = 0 z_deposit_position: float = 216.4 minimal_traverse_height_at_begin_of_command: Optional[float] = None @@ -62,12 +113,75 @@ class PickUpTipsParams(BackendParams): @dataclass class DropTipsParams(BackendParams): + """Vantage-specific parameters for ``drop_tips96``. + + Args: + z_deposit_position: Z deposit position in mm (collar bearing position) for the + 96-head. Default 216.4. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins. If None, uses the driver's ``traversal_height``. + Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command. + If None, uses the driver's ``traversal_height``. Must be between 0 and 360.0. + """ + z_deposit_position: float = 216.4 minimal_traverse_height_at_begin_of_command: Optional[float] = None minimal_height_at_command_end: Optional[float] = None @dataclass class AspirateParams(BackendParams): + """Vantage-specific parameters for ``aspirate96``. + + Unlike PIP parameters, these are scalar (not per-channel lists) because the 96-head + operates all channels identically in a single firmware command. + + Args: + jet: Flag used for liquid class selection. If True, selects a jet-mode liquid + class (aspirate from above the liquid surface). Default False. + blow_out: Flag used for liquid class selection. If True, selects a blow-out + liquid class. Default False. + hlc: Hamilton liquid class override. If None, auto-detected from tip type and + liquid. + type_of_aspiration: Type of aspiration (0 = simple, 1 = sequence, + 2 = cup emptied). Default 0. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins. If None, uses the driver's ``traversal_height``. + Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command. + If None, uses the driver's ``traversal_height``. Must be between 0 and 360.0. + pull_out_distance_to_take_transport_air_in_function_without_lld: Distance in mm + to pull out for transport air when not using LLD. Default 5.0. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from + minimum height in mm. Used for conical tubes. Default 0. + tube_2nd_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Default 0. + immersion_depth: Immersion depth in mm. Positive = deeper into liquid. + Default 0. + surface_following_distance: Surface following distance during aspiration in mm. + Default 0. + transport_air_volume: Transport air volume in uL. If None, uses the liquid class + default. + blow_out_air_volume: Blow-out air volume in uL. If None, uses the liquid class + default. + pre_wetting_volume: Pre-wetting volume in uL. Default 0. + lld_mode: LLD mode as integer (0 = OFF, 1 = GAMMA, 2 = PRESSURE, 3 = DUAL, + 4 = Z_TOUCH_OFF). Default 0 (OFF). + lld_sensitivity: Capacitive LLD sensitivity (1 = high, 4 = low). Default 4. + swap_speed: Swap speed (on leaving the liquid surface) in mm/s. If None, uses + the liquid class default. + settling_time: Settling time in seconds after aspiration completes. If None, + uses the liquid class default. + limit_curve_index: TADM limit curve index. Must be between 0 and 999. Default 0. + tadm_channel_pattern: List of 96 booleans selecting which channels participate in + TADM monitoring. If None, all 96 channels are active. + tadm_algorithm_on_off: TADM algorithm (0 = off, 1 = on). Default 0. + recording_mode: Recording mode for TADM (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Default 0. + disable_volume_correction: If True, skip liquid-class volume correction. + Default False. + """ + jet: bool = False blow_out: bool = False hlc: Optional[HamiltonLiquidClass] = None @@ -94,6 +208,65 @@ class AspirateParams(BackendParams): @dataclass class DispenseParams(BackendParams): + """Vantage-specific parameters for ``dispense96``. + + Unlike PIP parameters, these are scalar (not per-channel lists) because the 96-head + operates all channels identically in a single firmware command. + + Args: + jet: Flag used for liquid class selection. If True, selects a jet-mode liquid + class (dispense from above the liquid surface). Default False. + blow_out: Flag used for liquid class selection. If True, selects a blow-out + liquid class. Default False. + empty: If True, empty the tip completely at a fixed position (firmware mode 4). + Default False. + hlc: Hamilton liquid class override. If None, auto-detected from tip type and + liquid. + type_of_dispensing_mode: Firmware dispensing mode integer (0 = partial jet, + 1 = blow-out jet, 2 = partial surface, 3 = blow-out surface, 4 = empty at + fix position). If None, auto-computed from jet/empty/blow_out flags. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from + minimum height in mm. Used for conical tubes. Default 0. + tube_2nd_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Default 0. + pull_out_distance_to_take_transport_air_in_function_without_lld: Distance in mm + to pull out for transport air when not using LLD. Default 5.0. + immersion_depth: Immersion depth in mm. Positive = deeper into liquid. + Default 0. + surface_following_distance: Surface following distance during dispensing in mm. + Default 2.9. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins. If None, uses the driver's ``traversal_height``. + Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command. + If None, uses the driver's ``traversal_height``. Must be between 0 and 360.0. + cut_off_speed: Cut-off speed in uL/s. Speed at which dispensing transitions to + a slower final phase. Default 250.0. + stop_back_volume: Stop-back volume in uL. Volume retracted after dispensing to + prevent dripping. Default 0. + transport_air_volume: Transport air volume in uL. If None, uses the liquid class + default. + blow_out_air_volume: Blow-out air volume in uL. If None, uses the liquid class + default. + lld_mode: LLD mode as integer (0 = OFF, 1 = GAMMA, 2 = PRESSURE, 3 = DUAL, + 4 = Z_TOUCH_OFF). Default 0 (OFF). + lld_sensitivity: Capacitive LLD sensitivity (1 = high, 4 = low). Default 4. + side_touch_off_distance: Side touch-off distance in mm. The tips move laterally + by this distance after dispensing to break the droplet. Default 0 (disabled). + swap_speed: Swap speed (on leaving the liquid surface) in mm/s. If None, uses + the liquid class default. + settling_time: Settling time in seconds after dispensing completes. If None, + uses the liquid class default. + limit_curve_index: TADM limit curve index. Must be between 0 and 999. Default 0. + tadm_channel_pattern: List of 96 booleans selecting which channels participate in + TADM monitoring. If None, all 96 channels are active. + tadm_algorithm_on_off: TADM algorithm (0 = off, 1 = on). Default 0. + recording_mode: Recording mode for TADM (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Default 0. + disable_volume_correction: If True, skip liquid-class volume correction. + Default False. + """ + jet: bool = False blow_out: bool = False empty: bool = False @@ -128,6 +301,16 @@ async def pick_up_tips96( pickup: PickupTipRack, backend_params: Optional[BackendParams] = None, ): + """Pick up tips with the 96-head. + + Converts a PickupTipRack operation into a firmware TP command on module A1HM. + Registers the tip type and computes the A1 reference position from the tip rack. + + Args: + pickup: The tip-rack pickup operation. + backend_params: Optional :class:`VantageHead96Backend.PickUpTipsParams` for + Vantage-specific overrides. + """ if not isinstance(backend_params, VantageHead96Backend.PickUpTipsParams): backend_params = VantageHead96Backend.PickUpTipsParams() @@ -147,17 +330,15 @@ async def pick_up_tips96( try: await self._core96_tip_pick_up( - x_position=round(position.x * 10), - y_position=round(position.y * 10), + x_position=position.x, + y_position=position.y, tip_type=ttti, tip_handling_method=backend_params.tip_handling_method, - z_deposit_position=round((backend_params.z_deposit_position + pickup.offset.z) * 10), - minimal_traverse_height_at_begin_of_command=round( - (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 - ), - minimal_height_at_command_end=round( - (backend_params.minimal_height_at_command_end or th) * 10 + z_deposit_position=backend_params.z_deposit_position + pickup.offset.z, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th ), + minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -168,6 +349,19 @@ async def drop_tips96( drop: DropTipRack, backend_params: Optional[BackendParams] = None, ): + """Drop tips with the 96-head. + + Converts a DropTipRack operation into a firmware TR command on module A1HM. + Only TipRack targets are supported. + + Args: + drop: The tip-rack drop operation. + backend_params: Optional :class:`VantageHead96Backend.DropTipsParams` for + Vantage-specific overrides. + + Raises: + NotImplementedError: If the drop target is not a TipRack. + """ if not isinstance(backend_params, VantageHead96Backend.DropTipsParams): backend_params = VantageHead96Backend.DropTipsParams() @@ -183,15 +377,13 @@ async def drop_tips96( try: await self._core96_tip_discard( - x_position=round(position.x * 10), - y_position=round(position.y * 10), - z_deposit_position=round((backend_params.z_deposit_position + drop.offset.z) * 10), - minimal_traverse_height_at_begin_of_command=round( - (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 - ), - minimal_height_at_command_end=round( - (backend_params.minimal_height_at_command_end or th) * 10 + x_position=position.x, + y_position=position.y, + z_deposit_position=backend_params.z_deposit_position + drop.offset.z, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th ), + minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -202,6 +394,20 @@ async def aspirate96( aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], backend_params: Optional[BackendParams] = None, ): + """Aspirate liquid with the 96-head. + + Converts a multi-head aspiration operation into a firmware DA command on module A1HM. + Handles plate rotation (0 or 180 degrees around Z), liquid class resolution, volume + correction, LLD search height computation, and mix parameters. + + Args: + aspiration: The multi-head aspiration operation (plate or container). + backend_params: Optional :class:`VantageHead96Backend.AspirateParams` for + Vantage-specific overrides. + + Raises: + ValueError: If the plate has an unsupported rotation. + """ if not isinstance(backend_params, VantageHead96Backend.AspirateParams): backend_params = VantageHead96Backend.AspirateParams() @@ -277,40 +483,38 @@ async def aspirate96( try: await self._core96_aspiration_of_liquid( type_of_aspiration=backend_params.type_of_aspiration, - x_position=round(position.x * 10), - y_position=round(position.y * 10), - minimal_traverse_height_at_begin_of_command=round( - (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + x_position=position.x, + y_position=position.y, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th ), - minimal_height_at_command_end=round( - (backend_params.minimal_height_at_command_end or th) * 10 + minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), + lld_search_height=lld_search_height, + liquid_surface_at_function_without_lld=liquid_height, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld ), - lld_search_height=round(lld_search_height * 10), - liquid_surface_at_function_without_lld=round(liquid_height * 10), - pull_out_distance_to_take_transport_air_in_function_without_lld=round( - backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + minimum_height=well_bottoms, + tube_2nd_section_height_measured_from_zm=( + backend_params.tube_2nd_section_height_measured_from_zm ), - minimum_height=round(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=round( - backend_params.tube_2nd_section_height_measured_from_zm * 10 - ), - tube_2nd_section_ratio=round(backend_params.tube_2nd_section_ratio * 10), - immersion_depth=round(backend_params.immersion_depth * 10), - surface_following_distance=round(backend_params.surface_following_distance * 10), - aspiration_volume=round(volume * 100), - aspiration_speed=round(flow_rate * 10), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 100), - pre_wetting_volume=round(backend_params.pre_wetting_volume * 100), + tube_2nd_section_ratio=backend_params.tube_2nd_section_ratio, + immersion_depth=backend_params.immersion_depth, + surface_following_distance=backend_params.surface_following_distance, + aspiration_volume=volume, + aspiration_speed=flow_rate, + transport_air_volume=transport_air_volume, + blow_out_air_volume=blow_out_air_volume, + pre_wetting_volume=backend_params.pre_wetting_volume, lld_mode=backend_params.lld_mode, lld_sensitivity=backend_params.lld_sensitivity, - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mix_volume=round(aspiration.mix.volume * 100) if aspiration.mix is not None else 0, + swap_speed=swap_speed, + settling_time=settling_time, + mix_volume=aspiration.mix.volume if aspiration.mix is not None else 0, mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, mix_position_in_z_direction_from_liquid_surface=0, surface_following_distance_during_mixing=0, - mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 20, + mix_speed=aspiration.mix.flow_rate if aspiration.mix is not None else 2.0, limit_curve_index=backend_params.limit_curve_index, tadm_channel_pattern=backend_params.tadm_channel_pattern, tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, @@ -325,6 +529,20 @@ async def dispense96( dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], backend_params: Optional[BackendParams] = None, ): + """Dispense liquid with the 96-head. + + Converts a multi-head dispense operation into a firmware DD command on module A1HM. + Handles plate rotation (0 or 180 degrees around Z), liquid class resolution, volume + correction, dispensing mode selection, and mix parameters. + + Args: + dispense: The multi-head dispense operation (plate or container). + backend_params: Optional :class:`VantageHead96Backend.DispenseParams` for + Vantage-specific overrides. + + Raises: + ValueError: If the plate has an unsupported rotation. + """ if not isinstance(backend_params, VantageHead96Backend.DispenseParams): backend_params = VantageHead96Backend.DispenseParams() @@ -360,6 +578,7 @@ async def dispense96( well_bottoms = position.z lld_search_height = well_bottoms + dispense.container.get_absolute_size_z() + 1.7 + # +10mm offset on dispense liquid height. Ported from legacy. Not present on aspirate or STAR. liquid_height = position.z + (dispense.liquid_height or 0) + 10 tip = next(t for t in dispense.tips if t is not None) @@ -405,42 +624,40 @@ async def dispense96( try: await self._core96_dispensing_of_liquid( type_of_dispensing_mode=type_of_dispensing_mode, - x_position=round(position.x * 10), - y_position=round(position.y * 10), - minimum_height=round(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=round( - backend_params.tube_2nd_section_height_measured_from_zm * 10 - ), - tube_2nd_section_ratio=round(backend_params.tube_2nd_section_ratio * 10), - lld_search_height=round(lld_search_height * 10), - liquid_surface_at_function_without_lld=round(liquid_height * 10), - pull_out_distance_to_take_transport_air_in_function_without_lld=round( - backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + x_position=position.x, + y_position=position.y, + minimum_height=well_bottoms, + tube_2nd_section_height_measured_from_zm=( + backend_params.tube_2nd_section_height_measured_from_zm ), - immersion_depth=round(backend_params.immersion_depth * 10), - surface_following_distance=round(backend_params.surface_following_distance * 10), - minimal_traverse_height_at_begin_of_command=round( - (backend_params.minimal_traverse_height_at_begin_of_command or th) * 10 + tube_2nd_section_ratio=backend_params.tube_2nd_section_ratio, + lld_search_height=lld_search_height, + liquid_surface_at_function_without_lld=liquid_height, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld ), - minimal_height_at_command_end=round( - (backend_params.minimal_height_at_command_end or th) * 10 + immersion_depth=backend_params.immersion_depth, + surface_following_distance=backend_params.surface_following_distance, + minimal_traverse_height_at_begin_of_command=( + backend_params.minimal_traverse_height_at_begin_of_command or th ), - dispense_volume=round(volume * 100), - dispense_speed=round(flow_rate * 10), - cut_off_speed=round(backend_params.cut_off_speed * 10), - stop_back_volume=round(backend_params.stop_back_volume * 100), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 100), + minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), + dispense_volume=volume, + dispense_speed=flow_rate, + cut_off_speed=backend_params.cut_off_speed, + stop_back_volume=backend_params.stop_back_volume, + transport_air_volume=transport_air_volume, + blow_out_air_volume=blow_out_air_volume, lld_mode=backend_params.lld_mode, lld_sensitivity=backend_params.lld_sensitivity, - side_touch_off_distance=round(backend_params.side_touch_off_distance * 10), - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mix_volume=round(dispense.mix.volume * 100) if dispense.mix is not None else 0, + side_touch_off_distance=backend_params.side_touch_off_distance, + swap_speed=swap_speed, + settling_time=settling_time, + mix_volume=dispense.mix.volume if dispense.mix is not None else 0, mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, mix_position_in_z_direction_from_liquid_surface=0, surface_following_distance_during_mixing=0, - mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 10, + mix_speed=dispense.mix.flow_rate if dispense.mix is not None else 1.0, limit_curve_index=backend_params.limit_curve_index, tadm_channel_pattern=backend_params.tadm_channel_pattern, tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, @@ -454,81 +671,91 @@ async def dispense96( async def _core96_tip_pick_up( self, - x_position: int, - y_position: int, + x_position: float, + y_position: float, tip_type: int, tip_handling_method: int, - z_deposit_position: int, - minimal_traverse_height_at_begin_of_command: int, - minimal_height_at_command_end: int, + z_deposit_position: float, + minimal_traverse_height_at_begin_of_command: float, + minimal_height_at_command_end: float, ): - """Tip pick up using 96 head (A1HM:TP).""" + """Tip pick up using 96 head (A1HM:TP). + + All distances are in mm and are converted to firmware units (0.1mm) internally. + """ await self.driver.send_command( module="A1HM", command="TP", - xp=x_position, - yp=y_position, + xp=round(x_position * 10), + yp=round(y_position * 10), tt=tip_type, td=tip_handling_method, - tz=z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + tz=round(z_deposit_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + te=round(minimal_height_at_command_end * 10), ) async def _core96_tip_discard( self, - x_position: int, - y_position: int, - z_deposit_position: int, - minimal_traverse_height_at_begin_of_command: int, - minimal_height_at_command_end: int, + x_position: float, + y_position: float, + z_deposit_position: float, + minimal_traverse_height_at_begin_of_command: float, + minimal_height_at_command_end: float, ): - """Tip discard using 96 head (A1HM:TR).""" + """Tip discard using 96 head (A1HM:TR). + + All distances are in mm and are converted to firmware units (0.1mm) internally. + """ await self.driver.send_command( module="A1HM", command="TR", - xp=x_position, - yp=y_position, - tz=z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + xp=round(x_position * 10), + yp=round(y_position * 10), + tz=round(z_deposit_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + te=round(minimal_height_at_command_end * 10), ) async def _core96_aspiration_of_liquid( self, type_of_aspiration: int, - x_position: int, - y_position: int, - minimal_traverse_height_at_begin_of_command: int, - minimal_height_at_command_end: int, - lld_search_height: int, - liquid_surface_at_function_without_lld: int, - pull_out_distance_to_take_transport_air_in_function_without_lld: int, - minimum_height: int, - tube_2nd_section_height_measured_from_zm: int, - tube_2nd_section_ratio: int, - immersion_depth: int, - surface_following_distance: int, - aspiration_volume: int, - aspiration_speed: int, - transport_air_volume: int, - blow_out_air_volume: int, - pre_wetting_volume: int, + x_position: float, + y_position: float, + minimal_traverse_height_at_begin_of_command: float, + minimal_height_at_command_end: float, + lld_search_height: float, + liquid_surface_at_function_without_lld: float, + pull_out_distance_to_take_transport_air_in_function_without_lld: float, + minimum_height: float, + tube_2nd_section_height_measured_from_zm: float, + tube_2nd_section_ratio: float, + immersion_depth: float, + surface_following_distance: float, + aspiration_volume: float, + aspiration_speed: float, + transport_air_volume: float, + blow_out_air_volume: float, + pre_wetting_volume: float, lld_mode: int, lld_sensitivity: int, - swap_speed: int, - settling_time: int, - mix_volume: int, + swap_speed: float, + settling_time: float, + mix_volume: float, mix_cycles: int, mix_position_in_z_direction_from_liquid_surface: int, surface_following_distance_during_mixing: int, - mix_speed: int, + mix_speed: float, limit_curve_index: int, tadm_channel_pattern: Optional[List[bool]], tadm_algorithm_on_off: int, recording_mode: int, ): - """Aspiration of liquid using 96 head (A1HM:DA).""" + """Aspiration of liquid using 96 head (A1HM:DA). + + All parameters accept standard PLR units (mm, uL, uL/s, seconds) and are converted to + firmware units internally. + """ if tadm_channel_pattern is None: tadm_channel_pattern = [True] * 96 tadm_hex = _channel_pattern_to_hex(tadm_channel_pattern) @@ -537,32 +764,32 @@ async def _core96_aspiration_of_liquid( module="A1HM", command="DA", at=type_of_aspiration, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - zx=minimum_height, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - ip=immersion_depth, - fp=surface_following_distance, - av=aspiration_volume, - as_=aspiration_speed, - ta=transport_air_volume, - ba=blow_out_air_volume, - oa=pre_wetting_volume, + xp=round(x_position * 10), + yp=round(y_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + te=round(minimal_height_at_command_end * 10), + lp=round(lld_search_height * 10), + zl=round(liquid_surface_at_function_without_lld * 10), + po=round(pull_out_distance_to_take_transport_air_in_function_without_lld * 10), + zx=round(minimum_height * 10), + zu=round(tube_2nd_section_height_measured_from_zm * 10), + zr=round(tube_2nd_section_ratio * 10), + ip=round(immersion_depth * 10), + fp=round(surface_following_distance * 10), + av=round(aspiration_volume * 100), + as_=round(aspiration_speed * 10), + ta=round(transport_air_volume * 10), + ba=round(blow_out_air_volume * 100), + oa=round(pre_wetting_volume * 100), lm=lld_mode, ll=lld_sensitivity, - de=swap_speed, - wt=settling_time, - mv=mix_volume, + de=round(swap_speed * 10), + wt=round(settling_time * 10), + mv=round(mix_volume * 100), mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, mh=surface_following_distance_during_mixing, - ms=mix_speed, + ms=round(mix_speed * 10), gi=limit_curve_index, cw=tadm_hex, gj=tadm_algorithm_on_off, @@ -572,40 +799,44 @@ async def _core96_aspiration_of_liquid( async def _core96_dispensing_of_liquid( self, type_of_dispensing_mode: int, - x_position: int, - y_position: int, - minimum_height: int, - tube_2nd_section_height_measured_from_zm: int, - tube_2nd_section_ratio: int, - lld_search_height: int, - liquid_surface_at_function_without_lld: int, - pull_out_distance_to_take_transport_air_in_function_without_lld: int, - immersion_depth: int, - surface_following_distance: int, - minimal_traverse_height_at_begin_of_command: int, - minimal_height_at_command_end: int, - dispense_volume: int, - dispense_speed: int, - cut_off_speed: int, - stop_back_volume: int, - transport_air_volume: int, - blow_out_air_volume: int, + x_position: float, + y_position: float, + minimum_height: float, + tube_2nd_section_height_measured_from_zm: float, + tube_2nd_section_ratio: float, + lld_search_height: float, + liquid_surface_at_function_without_lld: float, + pull_out_distance_to_take_transport_air_in_function_without_lld: float, + immersion_depth: float, + surface_following_distance: float, + minimal_traverse_height_at_begin_of_command: float, + minimal_height_at_command_end: float, + dispense_volume: float, + dispense_speed: float, + cut_off_speed: float, + stop_back_volume: float, + transport_air_volume: float, + blow_out_air_volume: float, lld_mode: int, lld_sensitivity: int, - side_touch_off_distance: int, - swap_speed: int, - settling_time: int, - mix_volume: int, + side_touch_off_distance: float, + swap_speed: float, + settling_time: float, + mix_volume: float, mix_cycles: int, mix_position_in_z_direction_from_liquid_surface: int, surface_following_distance_during_mixing: int, - mix_speed: int, + mix_speed: float, limit_curve_index: int, tadm_channel_pattern: Optional[List[bool]], tadm_algorithm_on_off: int, recording_mode: int, ): - """Dispensing of liquid using 96 head (A1HM:DD).""" + """Dispensing of liquid using 96 head (A1HM:DD). + + All parameters accept standard PLR units (mm, uL, uL/s, seconds) and are converted to + firmware units internally. + """ if tadm_channel_pattern is None: tadm_channel_pattern = [True] * 96 tadm_hex = _channel_pattern_to_hex(tadm_channel_pattern) @@ -614,36 +845,181 @@ async def _core96_dispensing_of_liquid( module="A1HM", command="DD", dm=type_of_dispensing_mode, - xp=x_position, - yp=y_position, - zx=minimum_height, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - ip=immersion_depth, - fp=surface_following_distance, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - dv=dispense_volume, - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - ba=blow_out_air_volume, + xp=round(x_position * 10), + yp=round(y_position * 10), + zx=round(minimum_height * 10), + zu=round(tube_2nd_section_height_measured_from_zm * 10), + zr=round(tube_2nd_section_ratio * 10), + lp=round(lld_search_height * 10), + zl=round(liquid_surface_at_function_without_lld * 10), + po=round(pull_out_distance_to_take_transport_air_in_function_without_lld * 10), + ip=round(immersion_depth * 10), + fp=round(surface_following_distance * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + te=round(minimal_height_at_command_end * 10), + dv=round(dispense_volume * 100), + ds=round(dispense_speed * 10), + ss=round(cut_off_speed * 10), + rv=round(stop_back_volume * 100), + ta=round(transport_air_volume * 10), + ba=round(blow_out_air_volume * 100), lm=lld_mode, ll=lld_sensitivity, - dj=side_touch_off_distance, - de=swap_speed, - wt=settling_time, - mv=mix_volume, + dj=round(side_touch_off_distance * 10), + de=round(swap_speed * 10), + wt=round(settling_time * 10), + mv=round(mix_volume * 100), mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, mh=surface_following_distance_during_mixing, - ms=mix_speed, + ms=round(mix_speed * 10), gi=limit_curve_index, cw=tadm_hex, gj=tadm_algorithm_on_off, gk=recording_mode, ) + + async def _core96_move_to_defined_position( + self, + x_position: float = 500.0, + y_position: float = 500.0, + z_position: float = 0.0, + minimal_traverse_height_at_begin_of_command: float = 390.0, + ): + """Move 96 head to a defined position (A1HM:DN). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + z_position: Z position [mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + """ + await self.driver.send_command( + module="A1HM", + command="DN", + xp=round(x_position * 10), + yp=round(y_position * 10), + zp=round(z_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + ) + + async def _core96_wash_tips( + self, + x_position: float = 500.0, + y_position: float = 500.0, + liquid_surface_at_function_without_lld: float = 390.0, + minimum_height: float = 390.0, + surface_following_distance_during_mixing: float = 0.0, + minimal_traverse_height_at_begin_of_command: float = 390.0, + mix_volume: float = 0.0, + mix_cycles: int = 0, + mix_speed: float = 200.0, + ): + """Wash tips on the 96 head (A1HM:DW). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + liquid_surface_at_function_without_lld: Liquid surface without LLD [mm]. + minimum_height: Minimum height (maximum immersion depth) [mm]. + surface_following_distance_during_mixing: Surface following distance during mixing [mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + mix_volume: Mix volume [uL]. + mix_cycles: Number of mix cycles. + mix_speed: Mix speed [uL/s]. + """ + await self.driver.send_command( + module="A1HM", + command="DW", + xp=round(x_position * 10), + yp=round(y_position * 10), + zl=round(liquid_surface_at_function_without_lld * 10), + zx=round(minimum_height * 10), + mh=round(surface_following_distance_during_mixing * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), + mv=round(mix_volume * 100), + mc=mix_cycles, + ms=round(mix_speed * 10), + ) + + async def _core96_empty_washed_tips( + self, + liquid_surface_at_function_without_lld: float = 390.0, + minimal_height_at_command_end: float = 390.0, + ): + """Empty washed tips — end of wash procedure only (A1HM:EE). + + Args: + liquid_surface_at_function_without_lld: Liquid surface without LLD [mm]. + minimal_height_at_command_end: Minimal height at command end [mm]. + """ + await self.driver.send_command( + module="A1HM", + command="EE", + zl=round(liquid_surface_at_function_without_lld * 10), + te=round(minimal_height_at_command_end * 10), + ) + + async def _core96_search_for_teach_in_signal_in_x_direction( + self, + x_search_distance: float = 0.0, + x_speed: float = 5.0, + ): + """Search for Teach in signal in X direction on the 96 head (A1HM:DL). + + Args: + x_search_distance: X search distance [mm]. Must be between -5000.0 and 5000.0. + x_speed: X speed [mm/s]. Must be between 2.0 and 2500.0. + """ + if not -5000.0 <= x_search_distance <= 5000.0: + raise ValueError("x_search_distance must be in range -5000.0 to 5000.0") + if not 2.0 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 2.0 to 2500.0") + + await self.driver.send_command( + module="A1HM", + command="DL", + xs=round(x_search_distance * 10), + xv=round(x_speed * 10), + ) + + async def _core96_set_any_parameter(self): + """Set any parameter within the 96 head module (A1HM:AA).""" + await self.driver.send_command( + module="A1HM", + command="AA", + ) + + async def _core96_query_tip_presence(self): + """Query Tip presence on the 96 head (A1HM:QA).""" + return await self.driver.send_command( + module="A1HM", + command="QA", + ) + + async def _core96_request_position(self): + """Request position of the 96 head (A1HM:QI).""" + return await self.driver.send_command( + module="A1HM", + command="QI", + ) + + async def _core96_request_tadm_error_status( + self, + tadm_channel_pattern: Optional[List[bool]] = None, + ): + """Request TADM error status on the 96 head (A1HM:VB). + + Args: + tadm_channel_pattern: TADM Channel pattern (list of 96 booleans). If None, all 96 + channels are active. + """ + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + tadm_hex = _channel_pattern_to_hex(tadm_channel_pattern) + + return await self.driver.send_command( + module="A1HM", + command="VB", + cw=tadm_hex, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py index 065abbad6e1..9450e7d57f4 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py @@ -1,4 +1,4 @@ -"""Vantage IPG (Integrated Plate Gripper) backend: translates arm operations into firmware commands.""" +"""Vantage IPG (Integrated Plate Gripper) backend.""" from __future__ import annotations @@ -17,12 +17,8 @@ def _direction_degrees_to_grip_orientation(degrees: float) -> int: """Convert rotation angle in degrees to Vantage IPG grip orientation code. - The IPG uses numeric codes 1-44 for various orientations. The primary ones used for - plate manipulation are: - 32 = front grip (default) - 11 = right grip (90 degrees) - 31 = back grip (180 degrees) - 12 = left grip (270 degrees) + The IPG uses numeric codes 1-44 for various orientations. The primary ones: + 32 = front grip (default), 11 = right (90), 31 = back (180), 12 = left (270). """ normalized = round(degrees) % 360 mapping = {0: 32, 90: 11, 180: 31, 270: 12} @@ -32,11 +28,7 @@ def _direction_degrees_to_grip_orientation(degrees: float) -> int: class IPGBackend(OrientableGripperArmBackend): - """Backend for the Vantage Integrated Plate Gripper (IPG). - - Implements OrientableGripperArmBackend, translating arm operations into - firmware commands on module A1RM. - """ + """Backend for the Vantage Integrated Plate Gripper (IPG), module A1RM.""" def __init__(self, driver: "VantageDriver"): self.driver = driver @@ -47,7 +39,12 @@ def parked(self) -> bool: return self._parked async def _on_setup(self) -> None: - pass + """Check IPG initialization status, initialize if needed, and park if not parked.""" + initialized = await self.request_initialization_status() + if not initialized: + await self.initialize() + if not await self.get_parking_status(): + await self.park() async def _on_stop(self) -> None: if not self._parked: @@ -60,21 +57,39 @@ async def _on_stop(self) -> None: @dataclass class PickUpParams(BackendParams): + """Vantage IPG-specific parameters for gripping a plate. + + Args: + grip_strength: Grip strength (0-160). Default 100. + plate_width_tolerance: Plate width tolerance [mm]. Default 2.0. + acceleration_index: Acceleration index (0-4). Default 4. + z_clearance_height: Z clearance height [mm]. Default 5.0. + hotel_depth: Hotel depth [mm] (0 = stack mode). Default 0. + minimal_height_at_command_end: Minimum Z height at command end [mm]. Default 360.0. + """ + grip_strength: int = 100 - open_gripper_position: int = 860 - plate_width_tolerance: int = 20 + plate_width_tolerance: float = 2.0 acceleration_index: int = 4 - z_clearance_height: int = 50 - hotel_depth: int = 0 - minimal_height_at_command_end: int = 3600 + z_clearance_height: float = 5.0 + hotel_depth: float = 0.0 + minimal_height_at_command_end: float = 360.0 @dataclass class DropParams(BackendParams): - open_gripper_position: int = 860 - z_clearance_height: int = 50 - press_on_distance: int = 5 - hotel_depth: int = 0 - minimal_height_at_command_end: int = 3600 + """Vantage IPG-specific parameters for placing a plate. + + Args: + z_clearance_height: Z clearance height [mm]. Default 5.0. + press_on_distance: Press-on distance [mm]. Default 0.5. + hotel_depth: Hotel depth [mm] (0 = stack mode). Default 0. + minimal_height_at_command_end: Minimum Z height at command end [mm]. Default 360.0. + """ + + z_clearance_height: float = 5.0 + press_on_distance: float = 0.5 + hotel_depth: float = 0.0 + minimal_height_at_command_end: float = 360.0 # -- OrientableGripperArmBackend interface --------------------------------- @@ -89,20 +104,17 @@ async def pick_up_at_location( backend_params = IPGBackend.PickUpParams() grip_orientation = _direction_degrees_to_grip_orientation(direction) - th = round(self.driver.traversal_height * 10) - - await self._ipg_prepare_gripper_orientation( + await self.prepare_gripper_orientation( grip_orientation=grip_orientation, - minimal_traverse_height_at_begin_of_command=th, + minimal_traverse_height_at_begin_of_command=self.driver.traversal_height, ) - - await self._ipg_grip_plate( - x_position=round(location.x * 10), - y_position=round(location.y * 10), - z_position=round(location.z * 10), + await self.grip_plate( + x_position=location.x, + y_position=location.y, + z_position=location.z, grip_strength=backend_params.grip_strength, - open_gripper_position=backend_params.open_gripper_position, - plate_width=round(resource_width * 10), + open_gripper_position=resource_width + 3.2, + plate_width=resource_width - 3.3, plate_width_tolerance=backend_params.plate_width_tolerance, acceleration_index=backend_params.acceleration_index, z_clearance_height=backend_params.z_clearance_height, @@ -121,12 +133,13 @@ async def drop_at_location( if not isinstance(backend_params, IPGBackend.DropParams): backend_params = IPGBackend.DropParams() - await self._ipg_put_plate( - x_position=round(location.x * 10), - y_position=round(location.y * 10), - z_position=round(location.z * 10), - open_gripper_position=backend_params.open_gripper_position, + await self.put_plate( + x_position=location.x, + y_position=location.y, + z_position=location.z, + open_gripper_position=resource_width + 3.2, z_clearance_height=backend_params.z_clearance_height, + press_on_distance=backend_params.press_on_distance, hotel_depth=backend_params.hotel_depth, minimal_height_at_command_end=backend_params.minimal_height_at_command_end, ) @@ -137,18 +150,18 @@ async def move_to_location( direction: float, backend_params: Optional[BackendParams] = None, ) -> None: - th = round(self.driver.traversal_height * 10) - await self._ipg_move_to_defined_position( - x_position=round(location.x * 10), - y_position=round(location.y * 10), - z_position=round(location.z * 10), - minimal_traverse_height_at_begin_of_command=th, + await self.move_to_defined_position( + x_position=location.x, + y_position=location.y, + z_position=location.z, + minimal_traverse_height_at_begin_of_command=self.driver.traversal_height, ) async def halt(self, backend_params: Optional[BackendParams] = None) -> None: - pass # No explicit halt command for IPG. + raise NotImplementedError("halt is not implemented for the Vantage IPG.") async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the IPG (A1RM:GP).""" await self.driver.send_command(module="A1RM", command="GP") self._parked = True @@ -156,28 +169,27 @@ async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> GripperLocation: raise NotImplementedError( - "request_gripper_location is not yet implemented for the Vantage IPG. " - "The firmware response format for A1RM:QI needs to be reverse-engineered." + "request_gripper_location is not yet implemented for the Vantage IPG." ) async def open_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: + """Release object (A1RM:DO).""" await self.driver.send_command(module="A1RM", command="DO") async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: - # Closing is handled implicitly by grip_plate with the desired width. - pass + raise NotImplementedError("close_gripper is not implemented for the Vantage IPG.") async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: - return not self._parked + raise NotImplementedError("is_gripper_closed is not implemented for the Vantage IPG.") - # -- Initialization and status queries ------------------------------------- + # -- Initialization and status --------------------------------------------- async def request_initialization_status(self) -> bool: - """Check if the IPG module is initialized (A1RM:QW).""" + """Check if the IPG is initialized (A1RM:QW).""" resp = await self.driver.send_command(module="A1RM", command="QW", fmt={"qw": "int"}) return resp is not None and resp["qw"] == 1 @@ -186,94 +198,187 @@ async def initialize(self) -> None: await self.driver.send_command(module="A1RM", command="DI") async def get_parking_status(self) -> bool: - """Check if the IPG is parked (A1RM:RG). Returns True if parked.""" + """Check if the IPG is parked (A1RM:RG).""" resp = await self.driver.send_command(module="A1RM", command="RG", fmt={"rg": "int"}) parked = resp is not None and resp["rg"] == 1 self._parked = parked return parked - # -- firmware commands (A1RM) ---------------------------------------------- + # -- Firmware commands (A1RM) — all accept standard PLR units (mm) --------- - async def _ipg_prepare_gripper_orientation( + async def prepare_gripper_orientation( self, grip_orientation: int = 32, - minimal_traverse_height_at_begin_of_command: int = 3600, + minimal_traverse_height_at_begin_of_command: float = 360.0, ) -> None: - """Prepare gripper orientation (A1RM:GA).""" + """Prepare gripper orientation (A1RM:GA). + + Args: + grip_orientation: Grip orientation code (1-44). Default 32 (front). + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + """ await self.driver.send_command( module="A1RM", command="GA", gd=grip_orientation, - th=minimal_traverse_height_at_begin_of_command, + th=round(minimal_traverse_height_at_begin_of_command * 10), ) - async def _ipg_grip_plate( + async def grip_plate( self, - x_position: int, - y_position: int, - z_position: int, + x_position: float, + y_position: float, + z_position: float, grip_strength: int = 100, - open_gripper_position: int = 860, - plate_width: int = 800, - plate_width_tolerance: int = 20, + open_gripper_position: float = 86.0, + plate_width: float = 80.0, + plate_width_tolerance: float = 2.0, acceleration_index: int = 4, - z_clearance_height: int = 50, - hotel_depth: int = 0, - minimal_height_at_command_end: int = 3600, + z_clearance_height: float = 5.0, + hotel_depth: float = 0.0, + minimal_height_at_command_end: float = 360.0, ) -> None: - """Grip plate (A1RM:DG).""" + """Grip plate (A1RM:DG). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + z_position: Z position [mm]. + grip_strength: Grip strength (0-160). + open_gripper_position: Open gripper position [mm]. + plate_width: Plate width [mm]. + plate_width_tolerance: Plate width tolerance [mm]. + acceleration_index: Acceleration index (0-4). + z_clearance_height: Z clearance height [mm]. + hotel_depth: Hotel depth [mm] (0 = stack). + minimal_height_at_command_end: Minimal height at command end [mm]. + """ await self.driver.send_command( module="A1RM", command="DG", - xp=x_position, - yp=y_position, - zp=z_position, + xp=round(x_position * 10), + yp=round(y_position * 10), + zp=round(z_position * 10), yw=grip_strength, - yo=open_gripper_position, - yg=plate_width, - pt=plate_width_tolerance, + yo=round(open_gripper_position * 10), + yg=round(plate_width * 10), + pt=round(plate_width_tolerance * 10), ai=acceleration_index, - zc=z_clearance_height, - hd=hotel_depth, - te=minimal_height_at_command_end, + zc=round(z_clearance_height * 10), + hd=round(hotel_depth * 10), + te=round(minimal_height_at_command_end * 10), ) - async def _ipg_put_plate( + async def put_plate( self, - x_position: int, - y_position: int, - z_position: int, - open_gripper_position: int = 860, - z_clearance_height: int = 50, - hotel_depth: int = 0, - minimal_height_at_command_end: int = 3600, + x_position: float, + y_position: float, + z_position: float, + open_gripper_position: float = 86.0, + z_clearance_height: float = 5.0, + press_on_distance: float = 0.5, + hotel_depth: float = 0.0, + minimal_height_at_command_end: float = 360.0, ) -> None: - """Put plate (A1RM:DR).""" + """Put plate (A1RM:DR). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + z_position: Z position [mm]. + open_gripper_position: Open gripper position [mm]. + z_clearance_height: Z clearance height [mm]. + press_on_distance: Press-on distance [mm]. + hotel_depth: Hotel depth [mm] (0 = stack). + minimal_height_at_command_end: Minimal height at command end [mm]. + """ await self.driver.send_command( module="A1RM", command="DR", - xp=x_position, - yp=y_position, - zp=z_position, - yo=open_gripper_position, - zc=z_clearance_height, - hd=hotel_depth, - te=minimal_height_at_command_end, + xp=round(x_position * 10), + yp=round(y_position * 10), + zp=round(z_position * 10), + yo=round(open_gripper_position * 10), + zc=round(z_clearance_height * 10), + zi=round(press_on_distance * 10), + hd=round(hotel_depth * 10), + te=round(minimal_height_at_command_end * 10), ) - async def _ipg_move_to_defined_position( + async def move_to_defined_position( self, - x_position: int, - y_position: int, - z_position: int, - minimal_traverse_height_at_begin_of_command: int = 3600, + x_position: float, + y_position: float, + z_position: float, + minimal_traverse_height_at_begin_of_command: float = 360.0, ) -> None: - """Move to defined position (A1RM:DN).""" + """Move to defined position (A1RM:DN). + + Args: + x_position: X position [mm]. + y_position: Y position [mm]. + z_position: Z position [mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. + """ await self.driver.send_command( module="A1RM", command="DN", - xp=x_position, - yp=y_position, - zp=z_position, - th=minimal_traverse_height_at_begin_of_command, + xp=round(x_position * 10), + yp=round(y_position * 10), + zp=round(z_position * 10), + th=round(minimal_traverse_height_at_begin_of_command * 10), ) + + async def expose_channel_n(self) -> None: + """Expose channel n (A1RM:DQ).""" + await self.driver.send_command(module="A1RM", command="DQ") + + async def search_for_teach_in_signal_in_x_direction( + self, + x_search_distance: float = 0.0, + x_speed: float = 5.0, + ) -> None: + """Search for Teach in signal in X direction (A1RM:DL). + + Args: + x_search_distance: X search distance [mm]. + x_speed: X speed [mm/s]. + """ + await self.driver.send_command( + module="A1RM", + command="DL", + xs=round(x_search_distance * 10), + xv=round(x_speed * 10), + ) + + async def set_any_parameter_within_this_module(self) -> None: + """Set any parameter within this module (A1RM:AA).""" + await self.driver.send_command(module="A1RM", command="AA") + + async def query_tip_presence(self) -> None: + """Query tip presence (A1RM:QA).""" + await self.driver.send_command(module="A1RM", command="QA") + + async def request_access_range(self, grip_orientation: int = 32) -> None: + """Request access range (A1RM:QR). + + Args: + grip_orientation: Grip orientation (1-44). + """ + await self.driver.send_command(module="A1RM", command="QR", gd=grip_orientation) + + async def request_position(self, grip_orientation: int = 32) -> None: + """Request position (A1RM:QI). + + Args: + grip_orientation: Grip orientation (1-44). + """ + await self.driver.send_command(module="A1RM", command="QI", gd=grip_orientation) + + async def request_actual_angular_dimensions(self) -> None: + """Request actual angular dimensions (A1RM:RR).""" + await self.driver.send_command(module="A1RM", command="RR") + + async def request_configuration(self) -> None: + """Request configuration (A1RM:RS).""" + await self.driver.send_command(module="A1RM", command="RS") diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py b/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py index c85b61d7f0f..b95b94e7413 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py @@ -19,7 +19,10 @@ def __init__(self, driver: "VantageDriver"): self.driver = driver async def _on_setup(self): - pass + """Check loading cover initialization status and initialize if needed.""" + loading_cover_initialized = await self.request_initialization_status() + if not loading_cover_initialized: + await self.initialize() async def _on_stop(self): pass @@ -27,23 +30,23 @@ async def _on_stop(self): # -- commands (I1AM) ------------------------------------------------------- async def request_initialization_status(self) -> bool: - """Check if the loading cover module is initialized (I1AM:QW). + """Request the loading cover initialization status. Returns: - True if initialized, False otherwise. + True if the cover module is initialized, False otherwise. """ resp = await self.driver.send_command(module="I1AM", command="QW", fmt={"qw": "int"}) return resp is not None and resp["qw"] == 1 async def initialize(self) -> None: - """Initialize the loading cover module (I1AM:MI).""" + """Initialize the loading cover.""" await self.driver.send_command(module="I1AM", command="MI") async def set_cover(self, cover_open: bool) -> None: - """Open or close the loading cover (I1AM:LP). + """Set the loading cover. Args: - cover_open: True to open, False to close. + cover_open: Whether the cover should be open or closed. """ await self.driver.send_command(module="I1AM", command="LP", lp=not cover_open) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py index 25da9ce4f3c..5590b7994da 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -5,14 +5,14 @@ import enum import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union, cast from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop from pylabrobot.hamilton.lh.vantage.liquid_classes import get_vantage_liquid_class from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass -from pylabrobot.resources import Resource, Well +from pylabrobot.resources import Resource, Tip, Well from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.liquid import Liquid @@ -30,7 +30,21 @@ class LLDMode(enum.Enum): - """Liquid level detection mode.""" + """Liquid level detection mode for Vantage PIP channels. + + Controls how the pipetting channel detects the liquid surface inside a container. + + Attributes: + OFF: No liquid level detection. The channel moves to a fixed Z position. + GAMMA: Capacitive (gamma) liquid level detection. Detects the liquid surface by + measuring capacitance changes at the tip. + PRESSURE: Pressure-based liquid level detection. Detects the liquid surface by + monitoring pressure changes during descent. + DUAL: Dual LLD mode combining both capacitive and pressure detection for higher + reliability. Available for aspiration only. + Z_TOUCH_OFF: Z touch-off mode. The channel descends until mechanical contact is + detected. Turns off capacitive and pressure LLD. + """ OFF = 0 GAMMA = 1 @@ -45,14 +59,27 @@ class LLDMode(enum.Enum): def _get_dispense_mode(jet: bool, empty: bool, blow_out: bool) -> int: - """Compute firmware dispensing mode from boolean flags. + """Compute firmware dispensing mode integer from boolean flags. + + The Vantage firmware uses a single integer to encode the dispensing strategy. + This function maps the three orthogonal boolean flags to that integer. Firmware modes: - 0 = Partial volume in jet mode - 1 = Blow out in jet mode (labelled "empty" in VENUS) - 2 = Partial volume at surface - 3 = Blow out at surface (labelled "empty" in VENUS) - 4 = Empty tip at fix position + 0 = Partial volume in jet mode (dispense from above the liquid surface) + 1 = Blow out in jet mode (labelled "empty" in VENUS; full tip evacuation from above) + 2 = Partial volume at surface (dispense while tracking the liquid surface) + 3 = Blow out at surface (labelled "empty" in VENUS; full evacuation at surface) + 4 = Empty tip at fix position (complete tip emptying at a fixed Z) + + Args: + jet: If True, dispense in jet mode (tip above the liquid surface). + If False, dispense at the liquid surface. + empty: If True, empty the tip completely at a fixed position (overrides jet/blow_out). + blow_out: If True, perform a blow-out after dispensing. Combined with jet to + select between jet blow-out (mode 1) and surface blow-out (mode 3). + + Returns: + Integer firmware dispensing mode (0-4). """ if empty: return 4 @@ -66,9 +93,28 @@ def _ops_to_fw_positions( use_channels: List[int], num_channels: int, ) -> Tuple[List[int], List[int], List[bool]]: - """Convert ops + use_channels into firmware x/y positions and tip pattern. + """Convert operations and channel assignments into firmware x/y positions and tip pattern. + + Translates PLR operation objects (with absolute resource coordinates) into the + parallel arrays of x positions, y positions, and a boolean tip pattern that the + Vantage firmware expects. Unused channel slots are zero-padded. A minimum 9mm + Y-spacing check is enforced between channels sharing the same X position. + + Uses absolute coordinates (``get_absolute_location``) so the driver does not need + a ``deck`` reference. + + Args: + ops: Sequence of operations (Pickup, TipDrop, Aspiration, or Dispense). + use_channels: Sorted list of 0-indexed channel indices assigned to each operation. + num_channels: Total number of PIP channels on the instrument. + + Returns: + Tuple of (x_positions, y_positions, channels_involved) where positions are in + firmware units (0.1mm) and channels_involved is a boolean tip pattern. - Uses absolute coordinates so the driver does not need a ``deck`` reference. + Raises: + ValueError: If channels are not sorted, if too many channels are specified, + or if two channels on the same X are closer than 9mm in Y. """ if use_channels != sorted(use_channels): raise ValueError("Channels must be sorted.") @@ -121,7 +167,22 @@ def _resolve_liquid_classes( jet: Union[bool, List[bool]], blow_out: Union[bool, List[bool]], ) -> List[Optional[HamiltonLiquidClass]]: - """Resolve per-op Hamilton liquid classes. Auto-detect from tip if explicit is None.""" + """Resolve per-operation Hamilton liquid classes for the Vantage. + + If ``explicit`` is provided, returns it as-is (None entries are preserved, matching + legacy behavior). Otherwise, auto-detects a liquid class for each operation from the + tip's properties (volume, filter, size) using ``get_vantage_liquid_class``. + + Args: + explicit: User-provided list of liquid class overrides. Pass None to auto-detect. + ops: List of aspiration or dispense operations (must have ``.tip`` attributes). + jet: Per-channel or uniform flag selecting jet vs surface mode for liquid class lookup. + blow_out: Per-channel or uniform flag selecting blow-out mode for liquid class lookup. + + Returns: + List of resolved liquid classes (one per operation). Entries may be None if the + tip is not a HamiltonTip or if no matching liquid class is found. + """ n = len(ops) if isinstance(jet, bool): jet = [jet] * n @@ -157,13 +218,42 @@ def _resolve_liquid_classes( class VantagePIPBackend(PIPBackend): - """Translates PIP operations into Vantage firmware commands via the driver.""" + """Translates PIP (pipetting) operations into Vantage firmware commands via the driver. + + This backend implements the ``PIPBackend`` interface for the Hamilton Vantage. It converts + high-level ``pick_up_tips``, ``drop_tips``, ``aspirate``, and ``dispense`` calls into + low-level firmware commands on the A1PM module, handling coordinate conversion, liquid + class resolution, volume correction, and Z-height computation. + + Each public method accepts an optional ``BackendParams`` dataclass that exposes + Vantage-specific parameters (traverse heights, LLD settings, liquid class overrides, + etc.). When these parameters are None, sensible defaults are computed from resource + geometry, liquid classes, and the driver's ``traversal_height``. + """ def __init__(self, driver: "VantageDriver"): self.driver = driver async def _on_setup(self): - pass + """Check PIP initialization status and initialize channels if needed.""" + tip_presences = await self.driver.query_tip_presence() + pip_initialized = await self.driver.pip_request_initialization_status() + if not pip_initialized or any(tip_presences): + # FIXME: hardcoded for 8 channels. Will break on 4/12/16-channel Vantages. + # Pre-existing limitation from legacy. + default_y_positions = [389.1, 362.3, 335.5, 308.7, 281.9, 255.1, 228.3, 201.6] + n = self.driver.num_channels + th = self.driver.traversal_height + await self.driver.pip_initialize( + x_position=[709.5] * n, + y_position=default_y_positions, + begin_z_deposit_position=[th] * n, + end_z_deposit_position=[123.5] * n, + minimal_height_at_command_end=[th] * n, + tip_pattern=[True] * n, + tip_type=[1] * n, + TODO_DI_2=70, + ) async def _on_stop(self): pass @@ -172,25 +262,129 @@ async def _on_stop(self): def num_channels(self) -> int: return self.driver.num_channels + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + return True + # -- BackendParams dataclasses --------------------------------------------- @dataclass class PickUpTipsParams(BackendParams): - """Vantage-specific parameters for ``pick_up_tips``.""" + """Vantage-specific parameters for ``pick_up_tips``. + + All per-channel list parameters accept ``None`` to use sensible defaults (derived + from the driver's ``traversal_height``). When provided, lists must have one entry + per channel involved in the operation. + + Args: + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins, per channel. If None, uses the driver's + ``traversal_height``. Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command, + per channel. If None, uses the driver's ``traversal_height``. Must be between + 0 and 360.0. + """ minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None minimal_height_at_command_end: Optional[List[float]] = None @dataclass class DropTipsParams(BackendParams): - """Vantage-specific parameters for ``drop_tips``.""" + """Vantage-specific parameters for ``drop_tips``. + + All per-channel list parameters accept ``None`` to use sensible defaults (derived + from the driver's ``traversal_height``). When provided, lists must have one entry + per channel involved in the operation. + + Args: + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins, per channel. If None, uses the driver's + ``traversal_height``. Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command, + per channel. If None, uses the driver's ``traversal_height``. Must be between + 0 and 360.0. + """ minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None minimal_height_at_command_end: Optional[List[float]] = None @dataclass class AspirateParams(BackendParams): - """Vantage-specific parameters for ``aspirate``.""" + """Vantage-specific parameters for ``aspirate``. + + All per-channel list parameters accept ``None`` to use sensible defaults (typically + derived from liquid classes or container geometry). When provided, lists must have + one entry per channel involved in the operation. + + Args: + jet: Per-channel flag used for liquid class selection. If True, selects a jet-mode + liquid class. If None, defaults to [False] for all channels. + blow_out: Per-channel flag used for liquid class selection. If True, selects a + blow-out liquid class. If None, defaults to [False] for all channels. + hlcs: Per-channel Hamilton liquid class overrides. If None, auto-detected from + tip type and liquid. None entries in the list are preserved. + type_of_aspiration: Type of aspiration per channel (0 = simple, 1 = sequence, + 2 = cup emptied). If None, defaults to [0] for all channels. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins, per channel. If None, uses the driver's + ``traversal_height``. Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command, + per channel. If None, uses the driver's ``traversal_height``. Must be between + 0 and 360.0. + lld_search_height: LLD search height in mm (absolute Z). If None, auto-computed + from well geometry (well bottom + well height + 1.7mm for wells, +5mm for other + resources). + clot_detection_height: Clot detection height in mm above the liquid surface per + channel. If None, defaults to [0] for all channels. + liquid_surface_at_function_without_lld: Absolute liquid surface position in mm + when not using LLD, per channel. If None, computed from well bottom + liquid + height. + pull_out_distance_to_take_transport_air_in_function_without_lld: Distance in mm + to pull out for transport air when not using LLD, per channel. If None, + defaults to 10.9mm. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from + minimum height in mm, per channel. Used for conical tubes. If None, defaults + to [0]. + tube_2nd_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter, per channel. If None, defaults to [0]. + minimum_height: Minimum height (maximum immersion depth) in mm, per channel. + If None, uses the well bottom position. + immersion_depth: Immersion depth in mm, per channel. Positive = deeper into + liquid. If None, defaults to [0]. + surface_following_distance: Surface following distance during aspiration in mm, + per channel. If None, defaults to [0]. + transport_air_volume: Transport air volume in uL, per channel. If None, uses + the liquid class default. + pre_wetting_volume: Pre-wetting volume in uL, per channel. If None, defaults + to [0]. + lld_mode: LLD mode per channel as integer (0 = OFF, 1 = GAMMA, 2 = PRESSURE, + 3 = DUAL, 4 = Z_TOUCH_OFF). If None, defaults to [0] (OFF). + lld_sensitivity: Capacitive LLD sensitivity per channel (1 = high, 4 = low). + If None, defaults to [4]. + pressure_lld_sensitivity: Pressure LLD sensitivity per channel (1 = high, + 4 = low). If None, defaults to [4]. + aspirate_position_above_z_touch_off: Aspirate position above Z touch off in mm, + per channel. If None, defaults to [0.5]. + swap_speed: Swap speed (on leaving the liquid surface) in mm/s, per channel. + If None, defaults to [2]. + settling_time: Settling time in seconds after aspiration completes, per channel. + If None, defaults to [1.0]. + capacitive_mad_supervision_on_off: Capacitive MAD (Monitored Air Displacement) + supervision per channel (0 = off, 1 = on). If None, defaults to [0]. + pressure_mad_supervision_on_off: Pressure MAD supervision per channel + (0 = off, 1 = on). If None, defaults to [0]. + tadm_algorithm_on_off: TADM (Total Air Displacement Monitoring) algorithm + (0 = off, 1 = on). Applies to all channels. Default 0. + limit_curve_index: TADM limit curve index per channel. If None, defaults to [0]. + Must be between 0 and 999. + recording_mode: Recording mode for TADM (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Applies to all channels. Default 0. + disable_volume_correction: Per-channel flag to disable liquid-class volume + correction. If None, defaults to [False] for all channels. + """ jet: Optional[List[bool]] = None blow_out: Optional[List[bool]] = None @@ -224,7 +418,77 @@ class AspirateParams(BackendParams): @dataclass class DispenseParams(BackendParams): - """Vantage-specific parameters for ``dispense``.""" + """Vantage-specific parameters for ``dispense``. + + All per-channel list parameters accept ``None`` to use sensible defaults (typically + derived from liquid classes or container geometry). When provided, lists must have + one entry per channel involved in the operation. + + Args: + jet: Per-channel flag used for liquid class selection. If True, selects a jet-mode + liquid class (dispense from above the liquid surface). If None, defaults to + [False] for all channels. + blow_out: Per-channel flag used for liquid class selection. If True, selects a + blow-out liquid class. If None, defaults to [False] for all channels. + empty: Per-channel flag to empty the tip completely at a fixed position + (firmware mode 4). If None, defaults to [False] for all channels. + hlcs: Per-channel Hamilton liquid class overrides. If None, auto-detected from + tip type and liquid. None entries in the list are preserved. + type_of_dispensing_mode: Firmware dispensing mode per channel (0 = partial jet, + 1 = blow-out jet, 2 = partial surface, 3 = blow-out surface, 4 = empty at fix + position). If None, auto-computed from jet/empty/blow_out flags. + minimal_traverse_height_at_begin_of_command: Minimum Z clearance in mm before + lateral movement begins, per channel. If None, uses the driver's + ``traversal_height``. Must be between 0 and 360.0. + minimal_height_at_command_end: Minimum Z height in mm at the end of the command, + per channel. If None, uses the driver's ``traversal_height``. Must be between + 0 and 360.0. + lld_search_height: LLD search height in mm (absolute Z), per channel. If None, + auto-computed from well geometry (well bottom + well height + 1.7mm for wells, + +5mm for other resources). + minimum_height: Minimum height (maximum immersion depth) in mm, per channel. + If None, uses the well bottom position. + pull_out_distance_to_take_transport_air_in_function_without_lld: Distance in mm + to pull out for transport air when not using LLD, per channel. If None, + defaults to 5.0mm. + immersion_depth: Immersion depth in mm, per channel. Positive = deeper into + liquid. If None, defaults to [0]. + surface_following_distance: Surface following distance during dispense in mm, + per channel. If None, defaults to [2.1]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from + minimum height in mm, per channel. Used for conical tubes. If None, defaults + to [0]. + tube_2nd_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter, per channel. If None, defaults to [0]. + cut_off_speed: Cut-off speed in uL/s, per channel. Speed at which dispensing + transitions to a slower final phase. If None, defaults to [250]. + stop_back_volume: Stop-back volume in uL, per channel. Volume retracted after + dispensing to prevent dripping. If None, defaults to [0]. + transport_air_volume: Transport air volume in uL, per channel. If None, uses + the liquid class default. + lld_mode: LLD mode per channel as integer (0 = OFF, 1 = GAMMA, 2 = PRESSURE, + 3 = DUAL, 4 = Z_TOUCH_OFF). If None, defaults to [0] (OFF). + side_touch_off_distance: Side touch-off distance in mm. The tip moves laterally + by this distance after dispensing to break the droplet. Default 0 (disabled). + dispense_position_above_z_touch_off: Dispense position above Z touch off in mm, + per channel. If None, defaults to [0.5]. + lld_sensitivity: Capacitive LLD sensitivity per channel (1 = high, 4 = low). + If None, defaults to [1]. + pressure_lld_sensitivity: Pressure LLD sensitivity per channel (1 = high, + 4 = low). If None, defaults to [1]. + swap_speed: Swap speed (on leaving the liquid surface) in mm/s, per channel. + If None, defaults to [1]. + settling_time: Settling time in seconds after dispensing completes, per channel. + If None, defaults to [0]. + tadm_algorithm_on_off: TADM (Total Air Displacement Monitoring) algorithm + (0 = off, 1 = on). Applies to all channels. Default 0. + limit_curve_index: TADM limit curve index per channel. If None, defaults to [0]. + Must be between 0 and 999. + recording_mode: Recording mode for TADM (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Applies to all channels. Default 0. + disable_volume_correction: Per-channel flag to disable liquid-class volume + correction. If None, defaults to [False] for all channels. + """ jet: Optional[List[bool]] = None blow_out: Optional[List[bool]] = None @@ -263,6 +527,18 @@ async def pick_up_tips( use_channels: List[int], backend_params: Optional[BackendParams] = None, ): + """Pick up tips with the PIP channels. + + Converts high-level Pickup operations into a firmware TP command on module A1PM. + Handles tip type registration, Z-height computation from tip geometry, and + coordinate conversion to firmware units. + + Args: + ops: List of Pickup operations, one per channel. + use_channels: Sorted list of 0-indexed channel indices to use. + backend_params: Optional :class:`VantagePIPBackend.PickUpTipsParams` for + Vantage-specific overrides. + """ if not isinstance(backend_params, VantagePIPBackend.PickUpTipsParams): backend_params = VantagePIPBackend.PickUpTipsParams() @@ -294,10 +570,10 @@ async def pick_up_tips( y_position=y_positions, tip_pattern=tip_pattern, tip_type=ttti, - begin_z_deposit_position=[round((max_z + max_total_tip_length) * 10)] * len(ops), - end_z_deposit_position=[round((max_z + max_tip_length) * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), - minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + begin_z_deposit_position=[max_z + max_total_tip_length] * len(ops), + end_z_deposit_position=[max_z + max_tip_length] * len(ops), + minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), + minimal_height_at_command_end=list(mhe or [th]) * len(ops), tip_handling_method=[1] * len(ops), blow_out_air_volume=[0] * len(ops), ) @@ -313,6 +589,16 @@ async def drop_tips( use_channels: List[int], backend_params: Optional[BackendParams] = None, ): + """Drop tips from the PIP channels. + + Converts high-level TipDrop operations into a firmware TR command on module A1PM. + + Args: + ops: List of TipDrop operations, one per channel. + use_channels: Sorted list of 0-indexed channel indices to use. + backend_params: Optional :class:`VantagePIPBackend.DropTipsParams` for + Vantage-specific overrides. + """ if not isinstance(backend_params, VantagePIPBackend.DropTipsParams): backend_params = VantagePIPBackend.DropTipsParams() @@ -330,10 +616,10 @@ async def drop_tips( x_position=x_positions, y_position=y_positions, tip_pattern=channels_involved, - begin_z_deposit_position=[round((max_z + 10) * 10)] * len(ops), - end_z_deposit_position=[round(max_z * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), - minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), + begin_z_deposit_position=[max_z + 10] * len(ops), + end_z_deposit_position=[max_z] * len(ops), + minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), + minimal_height_at_command_end=list(mhe or [th]) * len(ops), tip_handling_method=[0] * len(ops), ) except VantageFirmwareError as e: @@ -359,6 +645,18 @@ async def aspirate( use_channels: List[int], backend_params: Optional[BackendParams] = None, ): + """Aspirate liquid with the PIP channels. + + Converts high-level Aspiration operations into a firmware DA command on module A1PM. + Handles liquid class resolution, volume correction, Z-height computation, LLD + configuration, and mix parameters. + + Args: + ops: List of Aspiration operations, one per channel. + use_channels: Sorted list of 0-indexed channel indices to use. + backend_params: Optional :class:`VantagePIPBackend.AspirateParams` for + Vantage-specific overrides. + """ if not isinstance(backend_params, VantagePIPBackend.AspirateParams): backend_params = VantagePIPBackend.AspirateParams() @@ -410,54 +708,44 @@ async def aspirate( y_position=y_positions, type_of_aspiration=backend_params.type_of_aspiration or [0] * len(ops), tip_pattern=channels_involved, - minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), - minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), - lld_search_height=[round(ls * 10) for ls in lld_search_heights], - clot_detection_height=[ - round(cdh * 10) for cdh in backend_params.clot_detection_height or [0] * len(ops) - ], - liquid_surface_at_function_without_lld=[round(lsn * 10) for lsn in liquid_surfaces_no_lld], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - round(pod * 10) - for pod in backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), + minimal_height_at_command_end=list(mhe or [th]) * len(ops), + lld_search_height=lld_search_heights, + clot_detection_height=list(backend_params.clot_detection_height or [0] * len(ops)), + liquid_surface_at_function_without_lld=liquid_surfaces_no_lld, + pull_out_distance_to_take_transport_air_in_function_without_lld=list( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld or [10.9] * len(ops) - ], - tube_2nd_section_height_measured_from_zm=[ - round(t * 10) - for t in backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ], - tube_2nd_section_ratio=[ - round(t * 10) for t in backend_params.tube_2nd_section_ratio or [0] * len(ops) - ], - minimum_height=[round(wb * 10) for wb in backend_params.minimum_height or well_bottoms], - immersion_depth=[round(d * 10) for d in backend_params.immersion_depth or [0] * len(ops)], - surface_following_distance=[ - round(d * 10) for d in backend_params.surface_following_distance or [0] * len(ops) - ], - aspiration_volume=[round(vol * 100) for vol in volumes], - aspiration_speed=[round(fr * 10) for fr in flow_rates], - transport_air_volume=[ - round(tav * 10) - for tav in backend_params.transport_air_volume + ), + tube_2nd_section_height_measured_from_zm=list( + backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ), + tube_2nd_section_ratio=list(backend_params.tube_2nd_section_ratio or [0] * len(ops)), + minimum_height=list(backend_params.minimum_height or well_bottoms), + immersion_depth=list(backend_params.immersion_depth or [0] * len(ops)), + surface_following_distance=list( + backend_params.surface_following_distance or [0] * len(ops) + ), + aspiration_volume=volumes, + aspiration_speed=flow_rates, + transport_air_volume=list( + backend_params.transport_air_volume or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ], - blow_out_air_volume=[round(bav * 100) for bav in blow_out_air_volumes], - pre_wetting_volume=[ - round(pwv * 100) for pwv in backend_params.pre_wetting_volume or [0] * len(ops) - ], + ), + blow_out_air_volume=blow_out_air_volumes, + pre_wetting_volume=list(backend_params.pre_wetting_volume or [0] * len(ops)), lld_mode=backend_params.lld_mode or [0] * len(ops), lld_sensitivity=backend_params.lld_sensitivity or [4] * len(ops), pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [4] * len(ops), - aspirate_position_above_z_touch_off=[ - round(apz * 10) - for apz in backend_params.aspirate_position_above_z_touch_off or [0.5] * len(ops) - ], - swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [2] * len(ops)], - settling_time=[round(st * 10) for st in backend_params.settling_time or [1] * len(ops)], - mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + aspirate_position_above_z_touch_off=list( + backend_params.aspirate_position_above_z_touch_off or [0.5] * len(ops) + ), + swap_speed=list(backend_params.swap_speed or [2] * len(ops)), + settling_time=list(backend_params.settling_time or [1] * len(ops)), + mix_volume=[op.mix.volume if op.mix is not None else 0 for op in ops], mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), - mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 2500 for op in ops], + mix_speed=[op.mix.flow_rate if op.mix is not None else 250 for op in ops], surface_following_distance_during_mixing=[0] * len(ops), capacitive_mad_supervision_on_off=( backend_params.capacitive_mad_supervision_on_off or [0] * len(ops) @@ -481,6 +769,18 @@ async def dispense( use_channels: List[int], backend_params: Optional[BackendParams] = None, ): + """Dispense liquid with the PIP channels. + + Converts high-level Dispense operations into a firmware DD command on module A1PM. + Handles liquid class resolution, volume correction, dispensing mode selection, + Z-height computation, and mix parameters. + + Args: + ops: List of Dispense operations, one per channel. + use_channels: Sorted list of 0-indexed channel indices to use. + backend_params: Optional :class:`VantagePIPBackend.DispenseParams` for + Vantage-specific overrides. + """ if not isinstance(backend_params, VantagePIPBackend.DispenseParams): backend_params = VantagePIPBackend.DispenseParams() @@ -535,53 +835,45 @@ async def dispense( y_position=y_positions, tip_pattern=channels_involved, type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=[round(wb * 10) for wb in backend_params.minimum_height or well_bottoms], - lld_search_height=[round(sh * 10) for sh in lld_search_heights], - liquid_surface_at_function_without_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - round(pod * 10) - for pod in backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + minimum_height=list(backend_params.minimum_height or well_bottoms), + lld_search_height=lld_search_heights, + liquid_surface_at_function_without_lld=liquid_surfaces_no_lld, + pull_out_distance_to_take_transport_air_in_function_without_lld=list( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld or [5.0] * len(ops) - ], - immersion_depth=[round(d * 10) for d in backend_params.immersion_depth or [0] * len(ops)], - surface_following_distance=[ - round(d * 10) for d in backend_params.surface_following_distance or [2.1] * len(ops) - ], - tube_2nd_section_height_measured_from_zm=[ - round(t * 10) - for t in backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ], - tube_2nd_section_ratio=[ - round(t * 10) for t in backend_params.tube_2nd_section_ratio or [0] * len(ops) - ], - minimal_traverse_height_at_begin_of_command=[round(t * 10) for t in mth or [th]] * len(ops), - minimal_height_at_command_end=[round(t * 10) for t in mhe or [th]] * len(ops), - dispense_volume=[round(vol * 100) for vol in volumes], - dispense_speed=[round(fr * 10) for fr in flow_rates], - cut_off_speed=[round(cs * 10) for cs in backend_params.cut_off_speed or [250] * len(ops)], - stop_back_volume=[ - round(sbv * 100) for sbv in backend_params.stop_back_volume or [0] * len(ops) - ], - transport_air_volume=[ - round(tav * 10) - for tav in backend_params.transport_air_volume + ), + immersion_depth=list(backend_params.immersion_depth or [0] * len(ops)), + surface_following_distance=list( + backend_params.surface_following_distance or [2.1] * len(ops) + ), + tube_2nd_section_height_measured_from_zm=list( + backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ), + tube_2nd_section_ratio=list(backend_params.tube_2nd_section_ratio or [0] * len(ops)), + minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), + minimal_height_at_command_end=list(mhe or [th]) * len(ops), + dispense_volume=volumes, + dispense_speed=flow_rates, + cut_off_speed=list(backend_params.cut_off_speed or [250] * len(ops)), + stop_back_volume=list(backend_params.stop_back_volume or [0] * len(ops)), + transport_air_volume=list( + backend_params.transport_air_volume or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ], - blow_out_air_volume=[round(boav * 100) for boav in blow_out_air_volumes], + ), + blow_out_air_volume=blow_out_air_volumes, lld_mode=backend_params.lld_mode or [0] * len(ops), - side_touch_off_distance=round(backend_params.side_touch_off_distance * 10), - dispense_position_above_z_touch_off=[ - round(dpz * 10) - for dpz in backend_params.dispense_position_above_z_touch_off or [0.5] * len(ops) - ], + side_touch_off_distance=backend_params.side_touch_off_distance, + dispense_position_above_z_touch_off=list( + backend_params.dispense_position_above_z_touch_off or [0.5] * len(ops) + ), lld_sensitivity=backend_params.lld_sensitivity or [1] * len(ops), pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [1] * len(ops), - swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [1] * len(ops)], - settling_time=[round(st * 10) for st in backend_params.settling_time or [0] * len(ops)], - mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + swap_speed=list(backend_params.swap_speed or [1] * len(ops)), + settling_time=list(backend_params.settling_time or [0] * len(ops)), + mix_volume=[op.mix.volume if op.mix is not None else 0 for op in ops], mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), - mix_speed=[round(op.mix.flow_rate * 100) if op.mix is not None else 10 for op in ops], + mix_speed=[op.mix.flow_rate if op.mix is not None else 1 for op in ops], surface_following_distance_during_mixing=[0] * len(ops), tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, limit_curve_index=backend_params.limit_curve_index or [0] * len(ops), @@ -594,6 +886,11 @@ async def dispense( # -- tip presence ---------------------------------------------------------- async def request_tip_presence(self) -> List[Optional[bool]]: + """Query whether each PIP channel currently has a tip attached. + + Returns: + List of booleans, one per channel. True if a tip is present, False otherwise. + """ presences = await self.driver.query_tip_presence() return [bool(p) for p in presences] @@ -605,27 +902,43 @@ async def _pip_tip_pick_up( y_position: List[int], tip_pattern: List[bool], tip_type: List[int], - begin_z_deposit_position: List[int], - end_z_deposit_position: List[int], - minimal_traverse_height_at_begin_of_command: List[int], - minimal_height_at_command_end: List[int], + begin_z_deposit_position: List[float], + end_z_deposit_position: List[float], + minimal_traverse_height_at_begin_of_command: List[float], + minimal_height_at_command_end: List[float], tip_handling_method: List[int], - blow_out_air_volume: List[int], + blow_out_air_volume: List[float], ): - """Tip pick up (A1PM:TP).""" + """Tip pick up (A1PM:TP). + + Args: + x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). + y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). + begin_z_deposit_position: Begin Z deposit position in mm. + end_z_deposit_position: End Z deposit position in mm. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command in mm. + minimal_height_at_command_end: Minimal height at command end in mm. + blow_out_air_volume: Blow out air volume in uL. + """ + # Convert from PLR standard units to firmware units right before send_command. + fw_begin_z = [round(z * 10) for z in begin_z_deposit_position] + fw_end_z = [round(z * 10) for z in end_z_deposit_position] + fw_th = [round(h * 10) for h in minimal_traverse_height_at_begin_of_command] + fw_te = [round(h * 10) for h in minimal_height_at_command_end] + fw_ba = [round(v * 100) for v in blow_out_air_volume] + await self.driver.send_command( module="A1PM", command="TP", - tip_pattern=tip_pattern, xp=x_position, yp=y_position, tm=tip_pattern, tt=tip_type, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - ba=blow_out_air_volume, + tp=fw_begin_z, + tz=fw_end_z, + th=fw_th, + te=fw_te, + ba=fw_ba, td=tip_handling_method, ) @@ -634,26 +947,41 @@ async def _pip_tip_discard( x_position: List[int], y_position: List[int], tip_pattern: List[bool], - begin_z_deposit_position: List[int], - end_z_deposit_position: List[int], - minimal_traverse_height_at_begin_of_command: List[int], - minimal_height_at_command_end: List[int], + begin_z_deposit_position: List[float], + end_z_deposit_position: List[float], + minimal_traverse_height_at_begin_of_command: List[float], + minimal_height_at_command_end: List[float], tip_handling_method: List[int], - ts: int = 0, + TODO_TR_2: int = 0, ): - """Tip discard (A1PM:TR).""" + """Tip discard (A1PM:TR). + + Args: + x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). + y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). + begin_z_deposit_position: Begin Z deposit position in mm. + end_z_deposit_position: End Z deposit position in mm. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command in mm. + minimal_height_at_command_end: Minimal height at command end in mm. + TODO_TR_2: Unknown firmware parameter (maps to firmware key ``ts``). + """ + # Convert from PLR standard units to firmware units right before send_command. + fw_begin_z = [round(z * 10) for z in begin_z_deposit_position] + fw_end_z = [round(z * 10) for z in end_z_deposit_position] + fw_th = [round(h * 10) for h in minimal_traverse_height_at_begin_of_command] + fw_te = [round(h * 10) for h in minimal_height_at_command_end] + await self.driver.send_command( module="A1PM", command="TR", - tip_pattern=tip_pattern, xp=x_position, yp=y_position, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + tp=fw_begin_z, + tz=fw_end_z, + th=fw_th, + te=fw_te, tm=tip_pattern, - ts=ts, + ts=TODO_TR_2, td=tip_handling_method, ) @@ -663,80 +991,140 @@ async def _pip_aspirate( y_position: List[int], type_of_aspiration: List[int], tip_pattern: List[bool], - minimal_traverse_height_at_begin_of_command: List[int], - minimal_height_at_command_end: List[int], - lld_search_height: List[int], - clot_detection_height: List[int], - liquid_surface_at_function_without_lld: List[int], - pull_out_distance_to_take_transport_air_in_function_without_lld: List[int], - tube_2nd_section_height_measured_from_zm: List[int], - tube_2nd_section_ratio: List[int], - minimum_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - aspiration_volume: List[int], - aspiration_speed: List[int], - transport_air_volume: List[int], - blow_out_air_volume: List[int], - pre_wetting_volume: List[int], + minimal_traverse_height_at_begin_of_command: List[float], + minimal_height_at_command_end: List[float], + lld_search_height: List[float], + clot_detection_height: List[float], + liquid_surface_at_function_without_lld: List[float], + pull_out_distance_to_take_transport_air_in_function_without_lld: List[float], + tube_2nd_section_height_measured_from_zm: List[float], + tube_2nd_section_ratio: List[float], + minimum_height: List[float], + immersion_depth: List[float], + surface_following_distance: List[float], + aspiration_volume: List[float], + aspiration_speed: List[float], + transport_air_volume: List[float], + blow_out_air_volume: List[float], + pre_wetting_volume: List[float], lld_mode: List[int], lld_sensitivity: List[int], pressure_lld_sensitivity: List[int], - aspirate_position_above_z_touch_off: List[int], - swap_speed: List[int], - settling_time: List[int], - mix_volume: List[int], + aspirate_position_above_z_touch_off: List[float], + swap_speed: List[float], + settling_time: List[float], + mix_volume: List[float], mix_cycles: List[int], mix_position_in_z_direction_from_liquid_surface: List[int], - mix_speed: List[int], + mix_speed: List[float], surface_following_distance_during_mixing: List[int], capacitive_mad_supervision_on_off: List[int], pressure_mad_supervision_on_off: List[int], tadm_algorithm_on_off: int = 0, limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, + TODO_DA_5: Optional[List[int]] = None, ): - """Aspiration of liquid (A1PM:DA).""" + """Aspiration of liquid (A1PM:DA). + + All distances are in mm, volumes in uL, speeds in uL/s, times in seconds. + Conversion to firmware units (0.1mm, 0.01uL, 0.1uL/s, 0.1s) happens internally. + + Args: + x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). + y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). + minimal_traverse_height_at_begin_of_command: mm. + minimal_height_at_command_end: mm. + lld_search_height: mm. + clot_detection_height: mm. + liquid_surface_at_function_without_lld: mm. + pull_out_distance_to_take_transport_air_in_function_without_lld: mm. + tube_2nd_section_height_measured_from_zm: mm. + tube_2nd_section_ratio: ratio (multiplied by 10 for firmware). + minimum_height: mm. + immersion_depth: mm. + surface_following_distance: mm. + aspiration_volume: uL. + aspiration_speed: uL/s. + transport_air_volume: uL. + blow_out_air_volume: uL. + pre_wetting_volume: uL. + aspirate_position_above_z_touch_off: mm. + swap_speed: mm/s. + settling_time: seconds. + mix_volume: uL. + mix_speed: uL/s. + TODO_DA_5: Unknown firmware parameter (maps to firmware key ``la``). Defaults to all zeros. + """ + # Convert from PLR standard units to firmware units right before send_command. + # Distances: mm -> 0.1mm (x10) + fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] + fw_te = [round(v * 10) for v in minimal_height_at_command_end] + fw_lp = [round(v * 10) for v in lld_search_height] + fw_ch = [round(v * 10) for v in clot_detection_height] + fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] + fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] + fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] + fw_zx = [round(v * 10) for v in minimum_height] + fw_ip = [round(v * 10) for v in immersion_depth] + fw_fp = [round(v * 10) for v in surface_following_distance] + fw_zo = [round(v * 10) for v in aspirate_position_above_z_touch_off] + # tube_2nd_section_ratio: ratio x10 for firmware + fw_zr = [round(v * 10) for v in tube_2nd_section_ratio] + # Volumes: uL -> 0.01uL (x100) + fw_av = [round(v * 100) for v in aspiration_volume] + fw_ba = [round(v * 100) for v in blow_out_air_volume] + fw_oa = [round(v * 100) for v in pre_wetting_volume] + fw_mv = [round(v * 100) for v in mix_volume] + # Transport air volume: uL -> 0.1uL (x10) + fw_ta = [round(v * 10) for v in transport_air_volume] + # Speeds: uL/s -> 0.1uL/s (x10) + fw_as = [round(v * 10) for v in aspiration_speed] + fw_ms = [round(v * 10) for v in mix_speed] + # swap_speed: mm/s -> 0.1mm/s (x10) + fw_de = [round(v * 10) for v in swap_speed] + # settling_time: s -> 0.1s (x10) + fw_wt = [round(v * 10) for v in settling_time] + await self.driver.send_command( module="A1PM", command="DA", - tip_pattern=tip_pattern, at=type_of_aspiration, tm=tip_pattern, xp=x_position, yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - lp=lld_search_height, - ch=clot_detection_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - zx=minimum_height, - ip=immersion_depth, - fp=surface_following_distance, - av=aspiration_volume, - as_=aspiration_speed, - ta=transport_air_volume, - ba=blow_out_air_volume, - oa=pre_wetting_volume, + th=fw_th, + te=fw_te, + lp=fw_lp, + ch=fw_ch, + zl=fw_zl, + po=fw_po, + zu=fw_zu, + zr=fw_zr, + zx=fw_zx, + ip=fw_ip, + fp=fw_fp, + av=fw_av, + as_=fw_as, + ta=fw_ta, + ba=fw_ba, + oa=fw_oa, lm=lld_mode, ll=lld_sensitivity, lv=pressure_lld_sensitivity, - zo=aspirate_position_above_z_touch_off, - de=swap_speed, - wt=settling_time, - mv=mix_volume, + zo=fw_zo, + de=fw_de, + wt=fw_wt, + mv=fw_mv, mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, - ms=mix_speed, + ms=fw_ms, mh=surface_following_distance_during_mixing, - la=[0] * len(x_position), + la=TODO_DA_5 if TODO_DA_5 is not None else [0] * len(type_of_aspiration), lb=capacitive_mad_supervision_on_off, lc=pressure_mad_supervision_on_off, gj=tadm_algorithm_on_off, - gi=limit_curve_index or [0] * len(x_position), + gi=limit_curve_index or [0] * len(type_of_aspiration), gk=recording_mode, ) @@ -746,77 +1134,958 @@ async def _pip_dispense( y_position: List[int], tip_pattern: List[bool], type_of_dispensing_mode: List[int], - minimum_height: List[int], - lld_search_height: List[int], - liquid_surface_at_function_without_lld: List[int], - pull_out_distance_to_take_transport_air_in_function_without_lld: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - tube_2nd_section_height_measured_from_zm: List[int], - tube_2nd_section_ratio: List[int], - minimal_traverse_height_at_begin_of_command: List[int], - minimal_height_at_command_end: List[int], - dispense_volume: List[int], - dispense_speed: List[int], - cut_off_speed: List[int], - stop_back_volume: List[int], - transport_air_volume: List[int], - blow_out_air_volume: List[int], + minimum_height: List[float], + lld_search_height: List[float], + liquid_surface_at_function_without_lld: List[float], + pull_out_distance_to_take_transport_air_in_function_without_lld: List[float], + immersion_depth: List[float], + surface_following_distance: List[float], + tube_2nd_section_height_measured_from_zm: List[float], + tube_2nd_section_ratio: List[float], + minimal_traverse_height_at_begin_of_command: List[float], + minimal_height_at_command_end: List[float], + dispense_volume: List[float], + dispense_speed: List[float], + cut_off_speed: List[float], + stop_back_volume: List[float], + transport_air_volume: List[float], + blow_out_air_volume: List[float], lld_mode: List[int], - side_touch_off_distance: int, - dispense_position_above_z_touch_off: List[int], + side_touch_off_distance: float, + dispense_position_above_z_touch_off: List[float], lld_sensitivity: List[int], pressure_lld_sensitivity: List[int], - swap_speed: List[int], - settling_time: List[int], - mix_volume: List[int], + swap_speed: List[float], + settling_time: List[float], + mix_volume: List[float], mix_cycles: List[int], mix_position_in_z_direction_from_liquid_surface: List[int], - mix_speed: List[int], + mix_speed: List[float], surface_following_distance_during_mixing: List[int], tadm_algorithm_on_off: int = 0, limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, + TODO_DD_2: Optional[List[int]] = None, ): - """Dispensing of liquid (A1PM:DD).""" + """Dispensing of liquid (A1PM:DD). + + All distances are in mm, volumes in uL, speeds in uL/s, times in seconds. + Conversion to firmware units (0.1mm, 0.01uL, 0.1uL/s, 0.1s) happens internally. + + Args: + x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). + y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). + minimum_height: mm. + lld_search_height: mm. + liquid_surface_at_function_without_lld: mm. + pull_out_distance_to_take_transport_air_in_function_without_lld: mm. + immersion_depth: mm. + surface_following_distance: mm. + tube_2nd_section_height_measured_from_zm: mm. + tube_2nd_section_ratio: ratio (multiplied by 10 for firmware). + minimal_traverse_height_at_begin_of_command: mm. + minimal_height_at_command_end: mm. + dispense_volume: uL. + dispense_speed: uL/s. + cut_off_speed: uL/s. + stop_back_volume: uL. + transport_air_volume: uL. + blow_out_air_volume: uL. + side_touch_off_distance: mm. + dispense_position_above_z_touch_off: mm. + swap_speed: mm/s. + settling_time: seconds. + mix_volume: uL. + mix_speed: uL/s. + TODO_DD_2: Unknown firmware parameter (maps to firmware key ``la``). Defaults to all zeros. + """ + # Convert from PLR standard units to firmware units right before send_command. + # Distances: mm -> 0.1mm (x10) + fw_zx = [round(v * 10) for v in minimum_height] + fw_lp = [round(v * 10) for v in lld_search_height] + fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] + fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] + fw_ip = [round(v * 10) for v in immersion_depth] + fw_fp = [round(v * 10) for v in surface_following_distance] + fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] + fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] + fw_te = [round(v * 10) for v in minimal_height_at_command_end] + fw_zo = [round(v * 10) for v in dispense_position_above_z_touch_off] + fw_dj = round(side_touch_off_distance * 10) + # tube_2nd_section_ratio: ratio x10 for firmware + fw_zr = [round(v * 10) for v in tube_2nd_section_ratio] + # Volumes: uL -> 0.01uL (x100) + fw_dv = [round(v * 100) for v in dispense_volume] + fw_rv = [round(v * 100) for v in stop_back_volume] + fw_ba = [round(v * 100) for v in blow_out_air_volume] + fw_mv = [round(v * 100) for v in mix_volume] + # Transport air volume: uL -> 0.1uL (x10) + fw_ta = [round(v * 10) for v in transport_air_volume] + # Speeds: uL/s -> 0.1uL/s (x10) + fw_ds = [round(v * 10) for v in dispense_speed] + fw_ss = [round(v * 10) for v in cut_off_speed] + fw_ms = [round(v * 10) for v in mix_speed] + # swap_speed: mm/s -> 0.1mm/s (x10) + fw_de = [round(v * 10) for v in swap_speed] + # settling_time: s -> 0.1s (x10) + fw_wt = [round(v * 10) for v in settling_time] + await self.driver.send_command( module="A1PM", command="DD", - tip_pattern=tip_pattern, dm=type_of_dispensing_mode, tm=tip_pattern, xp=x_position, yp=y_position, - zx=minimum_height, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - ip=immersion_depth, - fp=surface_following_distance, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - dv=[f"{vol:04}" for vol in dispense_volume], - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - ba=blow_out_air_volume, + zx=fw_zx, + lp=fw_lp, + zl=fw_zl, + po=fw_po, + ip=fw_ip, + fp=fw_fp, + zu=fw_zu, + zr=fw_zr, + th=fw_th, + te=fw_te, + dv=[f"{vol:04}" for vol in fw_dv], + ds=fw_ds, + ss=fw_ss, + rv=fw_rv, + ta=fw_ta, + ba=fw_ba, lm=lld_mode, - dj=side_touch_off_distance, - zo=dispense_position_above_z_touch_off, + dj=fw_dj, + zo=fw_zo, ll=lld_sensitivity, lv=pressure_lld_sensitivity, - de=swap_speed, - wt=settling_time, - mv=mix_volume, + de=fw_de, + wt=fw_wt, + mv=fw_mv, mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, - ms=mix_speed, + ms=fw_ms, mh=surface_following_distance_during_mixing, - la=[0] * len(x_position), + la=TODO_DD_2 if TODO_DD_2 is not None else [0] * len(type_of_dispensing_mode), gj=tadm_algorithm_on_off, - gi=limit_curve_index or [0] * len(x_position), + gi=limit_curve_index or [0] * len(type_of_dispensing_mode), gk=recording_mode, ) + + # -- positioning / query commands (A1PM) ----------------------------------- + + async def search_for_teach_in_signal_in_x_direction( + self, + channel_index: int = 1, + x_search_distance: float = 0, + x_speed: float = 27.0, + ): + """Search for teach-in signal in X direction (A1PM:DL). + + Args: + channel_index: Channel index (1-based, 1..16). + x_search_distance: X search distance in mm. + x_speed: X speed in mm/s. + """ + return await self.driver.send_command( + module="A1PM", + command="DL", + pn=channel_index, + xs=round(x_search_distance * 10), + xv=round(x_speed * 10), + ) + + async def position_all_channels_in_y_direction( + self, + y_position: List[float], + ): + """Position all channels in Y direction (A1PM:DY). + + Args: + y_position: Y positions in mm, one per channel. + """ + return await self.driver.send_command( + module="A1PM", + command="DY", + yp=[round(v * 10) for v in y_position], + ) + + async def position_all_channels_in_z_direction( + self, + z_position: List[float], + ): + """Position all channels in Z direction (A1PM:DZ). + + Args: + z_position: Z positions in mm, one per channel. + """ + return await self.driver.send_command( + module="A1PM", + command="DZ", + zp=[round(v * 10) for v in z_position], + ) + + async def position_single_channel_in_y_direction( + self, + channel_index: int = 1, + y_position: float = 300.0, + ): + """Position single channel in Y direction (A1PM:DV). + + Args: + channel_index: Channel index (1-based, 1..16). + y_position: Y position in mm. + """ + return await self.driver.send_command( + module="A1PM", + command="DV", + pn=channel_index, + yj=round(y_position * 10), + ) + + async def position_single_channel_in_z_direction( + self, + channel_index: int = 1, + z_position: float = 0, + ): + """Position single channel in Z direction (A1PM:DU). + + Args: + channel_index: Channel index (1-based, 1..16). + z_position: Z position in mm. + """ + return await self.driver.send_command( + module="A1PM", + command="DU", + pn=channel_index, + zj=round(z_position * 10), + ) + + async def move_to_defined_position( + self, + x_position: List[int], + y_position: List[float], + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + z_position: Optional[List[float]] = None, + ): + """Move to defined position (A1PM:DN). + + Args: + x_position: X positions in 0.1mm (firmware units). + y_position: Y positions in mm, one per channel. + tip_pattern: Channels involved (True = involved). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + in mm, one per channel. + z_position: Z positions in mm, one per channel. + """ + n = self.driver.num_channels + if tip_pattern is None: + tip_pattern = [False] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + if z_position is None: + z_position = [0.0] * n + + return await self.driver.send_command( + module="A1PM", + command="DN", + tm=tip_pattern, + xp=x_position, + yp=[round(v * 10) for v in y_position], + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + zp=[round(v * 10) for v in z_position], + ) + + async def teach_rack_using_channel_n( + self, + channel_index: int = 1, + gap_center_x_direction: float = 0, + gap_center_y_direction: float = 300.0, + gap_center_z_direction: float = 0, + minimal_height_at_command_end: Optional[List[float]] = None, + ): + """Teach rack using channel n (A1PM:DT). + + Attention! Channels not involved must first be taken out of measurement range. + + Args: + channel_index: Channel index (1-based, 1..16). + gap_center_x_direction: Gap center X direction in mm. + gap_center_y_direction: Gap center Y direction in mm. + gap_center_z_direction: Gap center Z direction in mm. + minimal_height_at_command_end: Minimal height at command end in mm, one per channel. + """ + n = self.driver.num_channels + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [360.0] * n + + return await self.driver.send_command( + module="A1PM", + command="DT", + pn=channel_index, + xa=round(gap_center_x_direction * 10), + yj=round(gap_center_y_direction * 10), + zj=round(gap_center_z_direction * 10), + te=[round(v * 10) for v in minimal_height_at_command_end], + ) + + async def expose_channel_n( + self, + channel_index: int = 1, + ): + """Expose channel n (A1PM:DQ). + + Args: + channel_index: Channel index (1-based, 1..16). + """ + return await self.driver.send_command( + module="A1PM", + command="DQ", + pn=channel_index, + ) + + async def calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom( + self, + TODO_DC_0: float = 0, + TODO_DC_1: float = 300.0, + tip_type: Optional[List[int]] = None, + TODO_DC_2: Optional[List[float]] = None, + z_deposit_position: Optional[List[float]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + first_pip_channel_node_no: int = 1, + ): + """Calculates check sums and compares them with the value saved in Flash EPROM (A1PM:DC). + + Args: + TODO_DC_0: Unknown parameter, in mm. + TODO_DC_1: Unknown parameter, in mm. + tip_type: Tip type (see command TT). + TODO_DC_2: Unknown parameter, in mm. + z_deposit_position: Z deposit position in mm (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + in mm. + first_pip_channel_node_no: First (lower) pip channel node no. (0 = disabled). + """ + n = self.driver.num_channels + if tip_type is None: + tip_type = [4] * n + if TODO_DC_2 is None: + TODO_DC_2 = [0.0] * n + if z_deposit_position is None: + z_deposit_position = [0.0] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + + return await self.driver.send_command( + module="A1PM", + command="DC", + xa=round(TODO_DC_0 * 10), + yj=round(TODO_DC_1 * 10), + tt=tip_type, + tp=[round(v * 10) for v in TODO_DC_2], + tz=[round(v * 10) for v in z_deposit_position], + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + pa=first_pip_channel_node_no, + ) + + async def set_any_parameter_within_this_module(self): + """Set any parameter within this module (A1PM:AA).""" + return await self.driver.send_command( + module="A1PM", + command="AA", + ) + + async def request_y_positions_of_all_channels(self) -> Dict: + """Request Y positions of all channels (A1PM:RY). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="RY", + ) + + async def request_y_position_of_channel_n(self, channel_index: int = 1) -> Dict: + """Request Y position of channel n (A1PM:RB). + + Args: + channel_index: Channel index (1-based). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="RB", + pn=channel_index, + ) + + async def request_z_positions_of_all_channels(self) -> Dict: + """Request Z positions of all channels (A1PM:RZ). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="RZ", + ) + + async def request_z_position_of_channel_n(self, channel_index: int = 1) -> Dict: + """Request Z position of channel n (A1PM:RD). + + Args: + channel_index: Channel index (1-based). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="RD", + pn=channel_index, + ) + + async def request_height_of_last_lld(self) -> Dict: + """Request height of last LLD (A1PM:RL). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="RL", + ) + + async def request_channel_dispense_on_fly_status(self) -> Dict: + """Request channel dispense on fly status (A1PM:QF). + + Returns: + Parsed firmware response dict. + """ + return await self.driver.send_command( + module="A1PM", + command="QF", + ) + + # -- advanced PIP commands (A1PM) ------------------------------------------ + + async def simultaneous_aspiration_dispensation_of_liquid( + self, + x_position: List[int], + y_position: List[int], + type_of_aspiration: Optional[List[int]] = None, + type_of_dispensing_mode: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + TODO_DM_1: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + aspiration_volume: Optional[List[float]] = None, + TODO_DM_3: Optional[List[float]] = None, + aspiration_speed: Optional[List[float]] = None, + dispense_volume: Optional[List[float]] = None, + dispense_speed: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + blow_out_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, + lld_mode: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + surface_following_distance_during_mixing: Optional[List[float]] = None, + TODO_DM_5: Optional[List[int]] = None, + capacitive_mad_supervision_on_off: Optional[List[int]] = None, + pressure_mad_supervision_on_off: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + """Simultaneous aspiration and dispensation of liquid (A1PM:DM). + + All distances are in mm, volumes in uL, speeds in uL/s (or mm/s for swap_speed), + times in seconds. Conversion to firmware units happens internally. + + Args: + x_position: X positions in 0.1mm (firmware units). + y_position: Y positions in 0.1mm (firmware units). + type_of_aspiration: Type of aspiration (0 = simple, 1 = sequence, 2 = cup emptied). + type_of_dispensing_mode: Dispensing mode (0..4). + tip_pattern: Channels involved. + TODO_DM_1: Unknown firmware parameter. + minimal_traverse_height_at_begin_of_command: mm. + minimal_height_at_command_end: mm. + lld_search_height: mm. + clot_detection_height: mm. + liquid_surface_at_function_without_lld: mm. + pull_out_distance_to_take_transport_air_in_function_without_lld: mm. + minimum_height: mm. + immersion_depth: mm. + surface_following_distance: mm. + tube_2nd_section_height_measured_from_zm: mm. + tube_2nd_section_ratio: ratio (raw firmware value, no conversion). + aspiration_volume: uL. + TODO_DM_3: uL. + aspiration_speed: uL/s. + dispense_volume: uL. + dispense_speed: uL/s. + cut_off_speed: uL/s. + stop_back_volume: uL. + transport_air_volume: uL. + blow_out_air_volume: uL. + pre_wetting_volume: uL. + lld_mode: LLD mode (0 = off). + aspirate_position_above_z_touch_off: mm. + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + pressure_lld_sensitivity: Pressure LLD sensitivity (1 = high, 4 = low). + swap_speed: Swap speed in mm/s. + settling_time: Settling time in seconds. + mix_volume: uL. + mix_cycles: Number of mix cycles. + mix_position_in_z_direction_from_liquid_surface: mm. + mix_speed: uL/s. + surface_following_distance_during_mixing: mm. + TODO_DM_5: Unknown firmware parameter. + capacitive_mad_supervision_on_off: 0 = off, 1 = on. + pressure_mad_supervision_on_off: 0 = off, 1 = on. + tadm_algorithm_on_off: 0 = off, 1 = on. + limit_curve_index: TADM limit curve index. + recording_mode: 0 = no, 1 = TADM errors only, 2 = all. + """ + n = self.driver.num_channels + if type_of_aspiration is None: + type_of_aspiration = [0] * n + if type_of_dispensing_mode is None: + type_of_dispensing_mode = [0] * n + if tip_pattern is None: + tip_pattern = [False] * n + if TODO_DM_1 is None: + TODO_DM_1 = [0] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [360.0] * n + if lld_search_height is None: + lld_search_height = [0.0] * n + if clot_detection_height is None: + clot_detection_height = [6.0] * n + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [360.0] * n + if pull_out_distance_to_take_transport_air_in_function_without_lld is None: + pull_out_distance_to_take_transport_air_in_function_without_lld = [5.0] * n + if minimum_height is None: + minimum_height = [360.0] * n + if immersion_depth is None: + immersion_depth = [0.0] * n + if surface_following_distance is None: + surface_following_distance = [0.0] * n + if tube_2nd_section_height_measured_from_zm is None: + tube_2nd_section_height_measured_from_zm = [0.0] * n + if tube_2nd_section_ratio is None: + tube_2nd_section_ratio = [0] * n + if aspiration_volume is None: + aspiration_volume = [0.0] * n + if TODO_DM_3 is None: + TODO_DM_3 = [0.0] * n + if aspiration_speed is None: + aspiration_speed = [50.0] * n + if dispense_volume is None: + dispense_volume = [0.0] * n + if dispense_speed is None: + dispense_speed = [50.0] * n + if cut_off_speed is None: + cut_off_speed = [25.0] * n + if stop_back_volume is None: + stop_back_volume = [0.0] * n + if transport_air_volume is None: + transport_air_volume = [0.0] * n + if blow_out_air_volume is None: + blow_out_air_volume = [0.0] * n + if pre_wetting_volume is None: + pre_wetting_volume = [0.0] * n + if lld_mode is None: + lld_mode = [1] * n + if aspirate_position_above_z_touch_off is None: + aspirate_position_above_z_touch_off = [0.5] * n + if lld_sensitivity is None: + lld_sensitivity = [1] * n + if pressure_lld_sensitivity is None: + pressure_lld_sensitivity = [1] * n + if swap_speed is None: + swap_speed = [10.0] * n + if settling_time is None: + settling_time = [0.5] * n + if mix_volume is None: + mix_volume = [0.0] * n + if mix_cycles is None: + mix_cycles = [0] * n + if mix_position_in_z_direction_from_liquid_surface is None: + mix_position_in_z_direction_from_liquid_surface = [25.0] * n + if mix_speed is None: + mix_speed = [50.0] * n + if surface_following_distance_during_mixing is None: + surface_following_distance_during_mixing = [0.0] * n + if TODO_DM_5 is None: + TODO_DM_5 = [0] * n + if capacitive_mad_supervision_on_off is None: + capacitive_mad_supervision_on_off = [0] * n + if pressure_mad_supervision_on_off is None: + pressure_mad_supervision_on_off = [0] * n + if limit_curve_index is None: + limit_curve_index = [0] * n + + # Convert from PLR standard units to firmware units. + # Distances: mm -> 0.1mm (x10) + fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] + fw_te = [round(v * 10) for v in minimal_height_at_command_end] + fw_lp = [round(v * 10) for v in lld_search_height] + fw_ch = [round(v * 10) for v in clot_detection_height] + fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] + fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] + fw_zx = [round(v * 10) for v in minimum_height] + fw_ip = [round(v * 10) for v in immersion_depth] + fw_fp = [round(v * 10) for v in surface_following_distance] + fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] + fw_zo = [round(v * 10) for v in aspirate_position_above_z_touch_off] + fw_mp = [round(v * 10) for v in mix_position_in_z_direction_from_liquid_surface] + fw_mh = [round(v * 10) for v in surface_following_distance_during_mixing] + # Volumes: uL -> 0.01uL (x100) + fw_av = [round(v * 100) for v in aspiration_volume] + fw_ar = [round(v * 100) for v in TODO_DM_3] + fw_dv = [round(v * 100) for v in dispense_volume] + fw_ba = [round(v * 100) for v in blow_out_air_volume] + # Speeds: uL/s -> 0.1uL/s (x10) + fw_as = [round(v * 10) for v in aspiration_speed] + fw_ds = [round(v * 10) for v in dispense_speed] + fw_ss = [round(v * 10) for v in cut_off_speed] + fw_ms = [round(v * 10) for v in mix_speed] + # stop_back_volume: uL -> 0.1uL (x10) + fw_rv = [round(v * 10) for v in stop_back_volume] + # transport_air_volume: uL -> 0.1uL (x10) + fw_ta = [round(v * 10) for v in transport_air_volume] + # pre_wetting_volume: uL -> 0.1uL (x10) + fw_oa = [round(v * 10) for v in pre_wetting_volume] + # mix_volume: uL -> 0.1uL (x10) + fw_mv = [round(v * 10) for v in mix_volume] + # swap_speed: mm/s -> 0.1mm/s (x10) + fw_de = [round(v * 10) for v in swap_speed] + # settling_time: s -> 0.1s (x10) + fw_wt = [round(v * 10) for v in settling_time] + + return await self.driver.send_command( + module="A1PM", + command="DM", + at=type_of_aspiration, + dm=type_of_dispensing_mode, + tm=tip_pattern, + dd=TODO_DM_1, + xp=x_position, + yp=y_position, + th=fw_th, + te=fw_te, + lp=fw_lp, + ch=fw_ch, + zl=fw_zl, + po=fw_po, + zx=fw_zx, + ip=fw_ip, + fp=fw_fp, + zu=fw_zu, + zr=tube_2nd_section_ratio, + av=fw_av, + ar=fw_ar, + as_=fw_as, + dv=fw_dv, + ds=fw_ds, + ss=fw_ss, + rv=fw_rv, + ta=fw_ta, + ba=fw_ba, + oa=fw_oa, + lm=lld_mode, + zo=fw_zo, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=fw_de, + wt=fw_wt, + mv=fw_mv, + mc=mix_cycles, + mp=fw_mp, + ms=fw_ms, + mh=fw_mh, + la=TODO_DM_5, + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + async def dispense_on_fly( + self, + y_position: List[float], + tip_pattern: Optional[List[bool]] = None, + first_shoot_x_pos: float = 0, + dispense_on_fly_pos_command_end: float = 0, + x_acceleration_distance_before_first_shoot: float = 10.0, + space_between_shoots: float = 9.0, + x_speed: float = 27.0, + number_of_shoots: int = 1, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + dispense_volume: Optional[List[float]] = None, + dispense_speed: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + """Dispense on fly (A1PM:DF). + + All distances in mm, volumes in uL, speeds in uL/s (or mm/s for x_speed). + + Args: + y_position: Y positions in mm. + tip_pattern: Channels involved. + first_shoot_x_pos: First shoot X position in mm. + dispense_on_fly_pos_command_end: Dispense on fly position on command end in mm. + x_acceleration_distance_before_first_shoot: X acceleration distance before first shoot + in mm. + space_between_shoots: Space between shoots (raster pitch) in mm (firmware uses 0.01mm). + x_speed: X speed in mm/s. + number_of_shoots: Number of shoots. + minimal_traverse_height_at_begin_of_command: mm. + minimal_height_at_command_end: mm. + liquid_surface_at_function_without_lld: mm. + dispense_volume: uL. + dispense_speed: uL/s. + cut_off_speed: uL/s. + stop_back_volume: uL. + transport_air_volume: uL. + tadm_algorithm_on_off: 0 = off, 1 = on. + limit_curve_index: TADM limit curve index. + recording_mode: 0 = no, 1 = TADM errors only, 2 = all. + """ + n = self.driver.num_channels + if tip_pattern is None: + tip_pattern = [False] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [360.0] * n + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [360.0] * n + if dispense_volume is None: + dispense_volume = [0.0] * n + if dispense_speed is None: + dispense_speed = [50.0] * n + if cut_off_speed is None: + cut_off_speed = [25.0] * n + if stop_back_volume is None: + stop_back_volume = [0.0] * n + if transport_air_volume is None: + transport_air_volume = [0.0] * n + if limit_curve_index is None: + limit_curve_index = [0] * n + + return await self.driver.send_command( + module="A1PM", + command="DF", + tm=tip_pattern, + xa=round(first_shoot_x_pos * 10), + xf=round(dispense_on_fly_pos_command_end * 10), + xh=round(x_acceleration_distance_before_first_shoot * 10), + xy=round(space_between_shoots * 100), + xv=round(x_speed * 10), + xi=number_of_shoots, + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + te=[round(v * 10) for v in minimal_height_at_command_end], + yp=[round(v * 10) for v in y_position], + zl=[round(v * 10) for v in liquid_surface_at_function_without_lld], + dv=[round(v * 100) for v in dispense_volume], + ds=[round(v * 10) for v in dispense_speed], + ss=[round(v * 10) for v in cut_off_speed], + rv=[round(v * 10) for v in stop_back_volume], + ta=[round(v * 10) for v in transport_air_volume], + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + async def nano_pulse_dispense( + self, + x_position: List[int], + y_position: List[float], + TODO_DB_0: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + TODO_DB_1: Optional[List[int]] = None, + TODO_DB_2: Optional[List[int]] = None, + TODO_DB_3: Optional[List[int]] = None, + TODO_DB_4: Optional[List[int]] = None, + TODO_DB_5: Optional[List[int]] = None, + TODO_DB_6: Optional[List[int]] = None, + TODO_DB_7: Optional[List[int]] = None, + TODO_DB_8: Optional[List[int]] = None, + TODO_DB_9: Optional[List[int]] = None, + TODO_DB_10: Optional[List[int]] = None, + TODO_DB_11: Optional[List[float]] = None, + TODO_DB_12: Optional[List[int]] = None, + ): + """Nano pulse dispense (A1PM:DB). + + Args: + x_position: X positions in 0.1mm (firmware units). + y_position: Y positions in mm. + TODO_DB_0: Unknown firmware parameter. + liquid_surface_at_function_without_lld: mm. + minimal_traverse_height_at_begin_of_command: mm. + minimal_height_at_command_end: mm. + TODO_DB_1..TODO_DB_12: Unknown firmware parameters (passed through as-is except + distance-like TODO_DB_11 which is in mm). + """ + n = self.driver.num_channels + if TODO_DB_0 is None: + TODO_DB_0 = [1] * n + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [360.0] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [360.0] * n + if TODO_DB_1 is None: + TODO_DB_1 = [0] * n + if TODO_DB_2 is None: + TODO_DB_2 = [0] * n + if TODO_DB_3 is None: + TODO_DB_3 = [0] * n + if TODO_DB_4 is None: + TODO_DB_4 = [0] * n + if TODO_DB_5 is None: + TODO_DB_5 = [0] * n + if TODO_DB_6 is None: + TODO_DB_6 = [0] * n + if TODO_DB_7 is None: + TODO_DB_7 = [0] * n + if TODO_DB_8 is None: + TODO_DB_8 = [0] * n + if TODO_DB_9 is None: + TODO_DB_9 = [0] * n + if TODO_DB_10 is None: + TODO_DB_10 = [0] * n + if TODO_DB_11 is None: + TODO_DB_11 = [0.0] * n + if TODO_DB_12 is None: + TODO_DB_12 = [1] * n + + return await self.driver.send_command( + module="A1PM", + command="DB", + tm=TODO_DB_0, + xp=x_position, + yp=[round(v * 10) for v in y_position], + zl=[round(v * 10) for v in liquid_surface_at_function_without_lld], + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + te=[round(v * 10) for v in minimal_height_at_command_end], + pe=TODO_DB_1, + pd=TODO_DB_2, + pf=TODO_DB_3, + pg=TODO_DB_4, + ph=TODO_DB_5, + pj=TODO_DB_6, + pk=TODO_DB_7, + pl=TODO_DB_8, + pp=TODO_DB_9, + pq=TODO_DB_10, + pi=[round(v * 10) for v in TODO_DB_11], + pm=TODO_DB_12, + ) + + async def wash_tips( + self, + x_position: List[int], + y_position: List[float], + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + aspiration_volume: Optional[List[float]] = None, + aspiration_speed: Optional[List[float]] = None, + dispense_speed: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + soak_time: int = 0, + wash_cycles: int = 0, + minimal_height_at_command_end: Optional[List[float]] = None, + ): + """Wash tips (A1PM:DW). + + All distances in mm, volumes in uL, speeds in uL/s (or mm/s for swap_speed). + + Args: + x_position: X positions in 0.1mm (firmware units). + y_position: Y positions in mm. + tip_pattern: Channels involved. + minimal_traverse_height_at_begin_of_command: mm. + liquid_surface_at_function_without_lld: mm. + aspiration_volume: uL. + aspiration_speed: uL/s. + dispense_speed: uL/s. + swap_speed: mm/s. + soak_time: Soak time (firmware value, no conversion). + wash_cycles: Number of wash cycles. + minimal_height_at_command_end: mm. + """ + n = self.driver.num_channels + if tip_pattern is None: + tip_pattern = [False] * n + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [360.0] * n + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [360.0] * n + if aspiration_volume is None: + aspiration_volume = [0.0] * n + if aspiration_speed is None: + aspiration_speed = [50.0] * n + if dispense_speed is None: + dispense_speed = [50.0] * n + if swap_speed is None: + swap_speed = [10.0] * n + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [360.0] * n + + return await self.driver.send_command( + module="A1PM", + command="DW", + tm=tip_pattern, + xp=x_position, + yp=[round(v * 10) for v in y_position], + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + zl=[round(v * 10) for v in liquid_surface_at_function_without_lld], + av=[round(v * 100) for v in aspiration_volume], + as_=[round(v * 10) for v in aspiration_speed], + ds=[round(v * 10) for v in dispense_speed], + de=[round(v * 10) for v in swap_speed], + sa=soak_time, + dc=wash_cycles, + te=[round(v * 10) for v in minimal_height_at_command_end], + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/errors_tests.py similarity index 100% rename from pylabrobot/hamilton/liquid_handlers/vantage/tests/test_errors.py rename to pylabrobot/hamilton/liquid_handlers/vantage/tests/errors_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py similarity index 100% rename from pylabrobot/hamilton/liquid_handlers/vantage/tests/test_fw_parsing.py rename to pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py index 9240e3eabef..11a6e58126c 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py @@ -15,11 +15,40 @@ class Vantage(Device): """Hamilton Vantage liquid handler. - User-facing device that wires capability frontends (PIP, Head96, IPG) to the - VantageDriver's backends after hardware discovery during setup(). + User-facing device that wires capability frontends (:class:`PIP`, :class:`Head96`, + :class:`OrientableArm`) to the :class:`VantageDriver`'s backends after hardware + discovery during :meth:`setup`. + + Usage:: + + from pylabrobot.resources.hamilton.vantage_decks import VantageDeck + from pylabrobot.hamilton.liquid_handlers.vantage import Vantage + + deck = VantageDeck() + vantage = Vantage(deck=deck) + await vantage.setup() + + # Use PIP channels: + await vantage.pip.pick_up_tips(...) + + # When done: + await vantage.stop() + + For testing without hardware, pass ``chatterbox=True``:: + + vantage = Vantage(deck=deck, chatterbox=True) + await vantage.setup() # no USB connection needed """ def __init__(self, deck: VantageDeck, chatterbox: bool = False): + """Initialize the Vantage device. + + Args: + deck: The deck definition describing the physical layout of the Vantage. + chatterbox: If True, use the :class:`VantageChatterboxDriver` (mock driver) + instead of the real :class:`VantageDriver`. Useful for testing, debugging, + and development without a physical instrument. + """ driver = VantageChatterboxDriver() if chatterbox else VantageDriver() super().__init__(driver=driver) self.driver: VantageDriver = driver @@ -29,6 +58,11 @@ def __init__(self, deck: VantageDeck, chatterbox: bool = False): self.ipg: Optional[OrientableArm] = None # set in setup() if installed async def setup(self): + """Initialize the Vantage hardware and wire up capability frontends. + + Calls :meth:`VantageDriver.setup` to discover and initialize hardware, then + creates PIP, Head96, and IPG frontend capabilities as appropriate. + """ await self.driver.setup() # PIP is always present. @@ -51,6 +85,11 @@ async def setup(self): self._setup_finished = True async def stop(self): + """Stop the Vantage device and tear down all capabilities. + + Stops all capability frontends in reverse order, then stops the driver. + Safe to call if setup was never completed (returns immediately). + """ if not self._setup_finished: return for cap in reversed(self._capabilities): diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py b/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py index 16b8ac37f1f..0a8cb74bca7 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py @@ -30,86 +30,90 @@ async def initialize(self) -> None: """Initialize the X-arm (A1XM:XI).""" await self.driver.send_command(module="A1XM", command="XI") - async def move_to_x_position( + async def move_to( self, - x_position: int = 5000, - x_speed: int = 25000, + x_position: float = 500.0, + x_speed: float = 2500.0, ) -> None: """Move arm to X position (A1XM:XP). Args: - x_position: X position [0.1mm]. Range -50000 to 50000. - x_speed: X speed [0.1mm/s]. Range 1 to 25000. + x_position: X position [mm]. Range -5000.0 to 5000.0. + x_speed: X speed [mm/s]. Range 0.1 to 2500.0. """ - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") + if not -5000.0 <= x_position <= 5000.0: + raise ValueError("x_position must be in range -5000.0 to 5000.0") + if not 0.1 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 0.1 to 2500.0") - await self.driver.send_command(module="A1XM", command="XP", xp=x_position, xv=x_speed) + await self.driver.send_command( + module="A1XM", command="XP", xp=round(x_position * 10), xv=round(x_speed * 10) + ) - async def move_to_x_position_safe( + async def move_to_safe( self, - x_position: int = 5000, - x_speed: int = 25000, + x_position: float = 500.0, + x_speed: float = 2500.0, xx: int = 1, ) -> None: """Move arm to X position with all attached components in Z-safety (A1XM:XA). Args: - x_position: X position [0.1mm]. - x_speed: X speed [0.1mm/s]. + x_position: X position [mm]. Range -5000.0 to 5000.0. + x_speed: X speed [mm/s]. Range 0.1 to 2500.0. xx: Unknown parameter. """ - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") + if not -5000.0 <= x_position <= 5000.0: + raise ValueError("x_position must be in range -5000.0 to 5000.0") + if not 0.1 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 0.1 to 2500.0") - await self.driver.send_command(module="A1XM", command="XA", xp=x_position, xv=x_speed, xx=xx) + await self.driver.send_command( + module="A1XM", command="XA", xp=round(x_position * 10), xv=round(x_speed * 10), xx=xx + ) async def move_relatively( self, - x_search_distance: int = 0, - x_speed: int = 25000, + x_search_distance: float = 0.0, + x_speed: float = 2500.0, xx: int = 1, ) -> None: """Move arm relatively in X (A1XM:XS). Args: - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. + x_search_distance: X search distance [mm]. Range -5000.0 to 5000.0. + x_speed: X speed [mm/s]. Range 0.1 to 2500.0. xx: Unknown parameter. """ - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") + if not -5000.0 <= x_search_distance <= 5000.0: + raise ValueError("x_search_distance must be in range -5000.0 to 5000.0") + if not 0.1 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 0.1 to 2500.0") await self.driver.send_command( - module="A1XM", command="XS", xs=x_search_distance, xv=x_speed, xx=xx + module="A1XM", command="XS", xs=round(x_search_distance * 10), xv=round(x_speed * 10), xx=xx ) async def search_teach_signal( self, - x_search_distance: int = 0, - x_speed: int = 25000, + x_search_distance: float = 0.0, + x_speed: float = 2500.0, xx: int = 1, ) -> None: """Search X for teach signal (A1XM:XT). Args: - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. + x_search_distance: X search distance [mm]. Range -5000.0 to 5000.0. + x_speed: X speed [mm/s]. Range 0.1 to 2500.0. xx: Unknown parameter. """ - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") + if not -5000.0 <= x_search_distance <= 5000.0: + raise ValueError("x_search_distance must be in range -5000.0 to 5000.0") + if not 0.1 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 0.1 to 2500.0") await self.driver.send_command( - module="A1XM", command="XT", xs=x_search_distance, xv=x_speed, xx=xx + module="A1XM", command="XT", xs=round(x_search_distance * 10), xv=round(x_speed * 10), xx=xx ) async def turn_off(self) -> None: @@ -123,3 +127,56 @@ async def request_position(self): async def request_error_code(self): """Request X-arm error code (A1XM:RE).""" return await self.driver.send_command(module="A1XM", command="RE") + + async def set_x_drive_angle_of_alignment( + self, + xl: int = 1, + ) -> None: + """Set X drive angle of alignment (A1XM:XL). + + Args: + xl: Alignment parameter. Range 1 to 1. + """ + if not 1 <= xl <= 1: + raise ValueError("xl must be in range 1 to 1") + + await self.driver.send_command(module="A1XM", command="XL", xl=xl) + + async def send_message_to_motion_controller( + self, + bd: str = "", + ): + """Send message to motion controller (A1XM:BD). + + Args: + bd: Message to send to the motion controller. + """ + return await self.driver.send_command(module="A1XM", command="BD", bd=bd) + + async def set_any_parameter_within_this_module( + self, + xm: int = 0, + xt: int = 1, + ): + """Set any parameter within this module (A1XM:AA). + + Args: + xm: Parameter index. + xt: Parameter value. + """ + return await self.driver.send_command(module="A1XM", command="AA", xm=xm, xt=xt) + + async def request_x_drive_recorded_data( + self, + lj: int = 0, + ln: int = 0, + ): + """Request X drive recorded data (A1RM:QL). + + Note: despite being an X-arm method, this sends to the A1RM module. + + Args: + lj: Data query parameter. + ln: Data query parameter. + """ + return await self.driver.send_command(module="A1RM", command="QL", lj=lj, ln=ln) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index dc446533a5c..90e862250c2 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -1,19 +1,50 @@ -import asyncio -import random import sys import warnings -from typing import Dict, List, Optional, Sequence, Union, cast +from typing import Dict, List, Optional, Sequence, Union +from pylabrobot.capabilities.liquid_handling.standard import ( + Aspiration as NewAspiration, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Dispense as NewDispense, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack as NewDropTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationContainer as NewMultiHeadAspirationContainer, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationPlate as NewMultiHeadAspirationPlate, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispenseContainer as NewMultiHeadDispenseContainer, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispensePlate as NewMultiHeadDispensePlate, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Pickup as NewPickup, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + PickupTipRack as NewPickupTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + TipDrop as NewTipDrop, +) +from pylabrobot.hamilton.liquid_handlers.vantage.head96_backend import VantageHead96Backend +from pylabrobot.hamilton.liquid_handlers.vantage.ipg import IPGBackend +from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( HamiltonLiquidHandler, ) from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, - get_vantage_liquid_class, ) from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, + GripDirection, MultiHeadAspirationContainer, MultiHeadAspirationPlate, MultiHeadDispenseContainer, @@ -28,15 +59,10 @@ ) from pylabrobot.resources import ( Coordinate, - Liquid, - Plate, Resource, Tip, - TipRack, - Well, ) from pylabrobot.resources.hamilton import ( - HamiltonTip, TipPickupMethod, TipSize, ) @@ -121,7 +147,6 @@ def __init__( self._iswap_parked: Optional[bool] = None self._num_channels: Optional[int] = None - self._traversal_height: float = 245.0 self._setup_done = False # -- property accessors for new-arch subsystems ---------------------------- @@ -155,6 +180,15 @@ def _vantage_loading_cover(self): assert self.driver.loading_cover is not None, "Loading cover is not available" return self.driver.loading_cover + @property + def _vantage_core_gripper(self): + """Typed access to the VantageCoreGripper backend.""" + from pylabrobot.hamilton.liquid_handlers.vantage.core import VantageCoreGripper + + if not hasattr(self, "_core_gripper_instance"): + self._core_gripper_instance = VantageCoreGripper(driver=self.driver) + return self._core_gripper_instance + @property def _write_and_read_command(self): return self.driver._write_and_read_command @@ -233,7 +267,6 @@ async def setup( # Sync legacy state from driver. self.id_ = 0 self._num_channels = self.driver.num_channels - self._traversal_height = self.driver.traversal_height self._setup_done = True async def stop(self): @@ -251,18 +284,13 @@ def num_channels(self) -> int: raise RuntimeError("num_channels is not set.") return self._num_channels - def set_minimum_traversal_height(self, traversal_height: float): - """Set the minimum traversal height for the robot. - - This refers to the bottom of the pipetting channel when no tip is present, or the bottom of the - tip when a tip is present. This value will be used as the default value for the - `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters - unless they are explicitly set. - """ - - assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + @property + def _traversal_height(self) -> float: + return self.driver.traversal_height - self._traversal_height = traversal_height + def set_minimum_traversal_height(self, traversal_height: float): + """Deprecated: use ``VantageDriver.set_minimum_traversal_height``.""" + self.driver.set_minimum_traversal_height(traversal_height) # ============== LiquidHandlerBackend methods ============== @@ -273,43 +301,16 @@ async def pick_up_tips( minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, minimal_height_at_command_end: Optional[List[float]] = None, ): - x_positions, y_positions, tip_pattern = self._ops_to_fw_positions(ops, use_channels) - - tips = [cast(HamiltonTip, op.resource.get_tip()) for op in ops] - ttti = [await self.get_or_assign_tip_type_index(tip) for tip in tips] - - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - - # not sure why this is necessary, but it is according to log files and experiments - if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: - max_tip_length += 2 - elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - max_tip_length -= 2 - - try: - return await self.pip_tip_pick_up( - x_position=x_positions, - y_position=y_positions, - tip_pattern=tip_pattern, - tip_type=ttti, - begin_z_deposit_position=[round((max_z + max_total_tip_length) * 10)] * len(ops), - end_z_deposit_position=[round((max_z + max_tip_length) * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[ - round(th * 10) - for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] - ] - * len(ops), - minimal_height_at_command_end=[ - round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] - ] - * len(ops), - tip_handling_method=[1 for _ in tips], # always appears to be 1 # tip.pickup_method.value - blow_out_air_volume=[0] * len(ops), # Why is this here? Who knows. - ) - except Exception as e: - raise e + """Deprecated: use ``VantagePIPBackend.pick_up_tips``.""" + new_ops = [NewPickup(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + await self._vantage_pip.pick_up_tips( + new_ops, + use_channels, + backend_params=VantagePIPBackend.PickUpTipsParams( + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + ), + ) # @need_iswap_parked async def drop_tips( @@ -319,35 +320,16 @@ async def drop_tips( minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, minimal_height_at_command_end: Optional[List[float]] = None, ): - """Drop tips to a resource.""" - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - - try: - return await self.pip_tip_discard( - x_position=x_positions, - y_position=y_positions, - tip_pattern=channels_involved, - begin_z_deposit_position=[round((max_z + 10) * 10)] * len(ops), # +10 - end_z_deposit_position=[round(max_z * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[ - round(th * 10) - for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] - ] - * len(ops), - minimal_height_at_command_end=[ - round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] - ] - * len(ops), - tip_handling_method=[0 for _ in ops], # Always appears to be 0, even in trash. - # tip_handling_method=[TipDropMethod.DROP.value if isinstance(op.resource, TipSpot) \ - # else TipDropMethod.PLACE_SHIFT.value for op in ops], - TODO_TR_2=0, - ) - except Exception as e: - raise e + """Deprecated: use ``VantagePIPBackend.drop_tips``.""" + new_ops = [NewTipDrop(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + await self._vantage_pip.drop_tips( + new_ops, + use_channels, + backend_params=VantagePIPBackend.DropTipsParams( + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + ), + ) def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: """Assert that resources are in a valid location for pipetting.""" @@ -397,150 +379,66 @@ async def aspirate( recording_mode: int = 0, disable_volume_correction: Optional[List[bool]] = None, ): - """Aspirate from (a) resource(s). - - See :meth:`pip_aspirate` (the firmware command) for parameter documentation. This method serves - as a wrapper for that command, and will convert operations into the appropriate format. This - method additionally provides default values based on firmware instructions sent by Venus on - Vantage, rather than machine default values (which are often not what you want). - - Args: - ops: The aspiration operations. - use_channels: The channels to use. - blow_out: Whether to search for a "blow out" liquid class. This is only used on dispense. - Note that in the VENUS liquid editor, the term "empty" is used for this, but in the firmware - documentation, "empty" is used for a different mode (dm4). - hlcs: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be - determined automatically based on the tip and liquid used. - disable_volume_correction: Whether to disable volume correction for each operation. - """ - + """Deprecated: use ``VantagePIPBackend.aspirate``.""" + # Legacy mix kwargs are not supported; raise early. if mix_volume is not None or mix_cycles is not None or mix_speed is not None: raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of " + "LiquidHandler.dispense instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - if jet is None: - jet = [False] * len(ops) - if blow_out is None: - blow_out = [False] * len(ops) - - if hlcs is None: - hlcs = [] - for j, bo, op in zip(jet, blow_out, ops): - hlcs.append( - get_vantage_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=j, - blow_out=bo, - ) - ) - - self._assert_valid_resources([op.resource for op in ops]) - - # correct volumes using the liquid class if not disabled - disable_volume_correction = disable_volume_correction or [False] * len(ops) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) - ] - - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + new_ops = [ + NewAspiration( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, + ) for op in ops ] - liquid_surfaces_no_lld = liquid_surface_at_function_without_lld or [ - wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops) - ] - # -1 compared to STAR? - lld_search_heights = lld_search_height or [ - wb - + op.resource.get_absolute_size_z() - + (2.7 - 1 if isinstance(op.resource, Well) else 5) # ? - for wb, op in zip(well_bottoms, ops) - ] - - flow_rates = [ - op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) - for op, hlc in zip(ops, hlcs) - ] - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0)) - for op, hlc in zip(ops, hlcs) - ] - - return await self.pip_aspirate( - x_position=x_positions, - y_position=y_positions, - type_of_aspiration=type_of_aspiration or [0] * len(ops), - tip_pattern=channels_involved, - minimal_traverse_height_at_begin_of_command=[ - round(th * 10) - for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] - ] - * len(ops), - minimal_height_at_command_end=[ - round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] - ] - * len(ops), - lld_search_height=[round(ls * 10) for ls in lld_search_heights], - clot_detection_height=[round(cdh * 10) for cdh in clot_detection_height or [0] * len(ops)], - liquid_surface_at_function_without_lld=[round(lsn * 10) for lsn in liquid_surfaces_no_lld], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - round(pod * 10) - for pod in pull_out_distance_to_take_transport_air_in_function_without_lld - or [10.9] * len(ops) - ], - tube_2nd_section_height_measured_from_zm=[ - round(t2sh * 10) for t2sh in tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ], - tube_2nd_section_ratio=[ - round(t2sr * 10) for t2sr in tube_2nd_section_ratio or [0] * len(ops) - ], - minimum_height=[round(wb * 10) for wb in minimum_height or well_bottoms], - immersion_depth=[round(id_ * 10) for id_ in immersion_depth or [0] * len(ops)], - surface_following_distance=[ - round(sfd * 10) for sfd in surface_following_distance or [0] * len(ops) - ], - aspiration_volume=[round(vol * 100) for vol in volumes], - aspiration_speed=[round(fr * 10) for fr in flow_rates], - transport_air_volume=[ - round(tav * 10) - for tav in transport_air_volume - or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ], - blow_out_air_volume=[round(bav * 100) for bav in blow_out_air_volumes], - pre_wetting_volume=[round(pwv * 100) for pwv in pre_wetting_volume or [0] * len(ops)], - lld_mode=lld_mode or [0] * len(ops), - lld_sensitivity=lld_sensitivity or [4] * len(ops), - pressure_lld_sensitivity=pressure_lld_sensitivity or [4] * len(ops), - aspirate_position_above_z_touch_off=[ - round(apz * 10) for apz in aspirate_position_above_z_touch_off or [0.5] * len(ops) - ], - swap_speed=[round(ss * 10) for ss in swap_speed or [2] * len(ops)], - settling_time=[round(st * 10) for st in settling_time or [1] * len(ops)], - mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], - mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], - mix_position_in_z_direction_from_liquid_surface=[ - round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) - ], - mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 2500 for op in ops], - surface_following_distance_during_mixing=[ - round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) - ], - TODO_DA_5=TODO_DA_5 or [0] * len(ops), - capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off or [0] * len(ops), - pressure_mad_supervision_on_off=pressure_mad_supervision_on_off or [0] * len(ops), - tadm_algorithm_on_off=tadm_algorithm_on_off or 0, - limit_curve_index=limit_curve_index or [0] * len(ops), - recording_mode=recording_mode or 0, + # TODO_DA_5, mix_position_in_z_direction_from_liquid_surface, + # surface_following_distance_during_mixing have no BackendParams equivalent; dropped. + await self._vantage_pip.aspirate( + new_ops, + use_channels, + backend_params=VantagePIPBackend.AspirateParams( + jet=jet, + blow_out=blow_out, + hlcs=hlcs, + type_of_aspiration=type_of_aspiration, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + lld_search_height=lld_search_height, + clot_detection_height=clot_detection_height, + liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + pull_out_distance_to_take_transport_air_in_function_without_lld + ), + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, + tube_2nd_section_ratio=tube_2nd_section_ratio, + minimum_height=minimum_height, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + transport_air_volume=transport_air_volume, + pre_wetting_volume=pre_wetting_volume, + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + pressure_lld_sensitivity=pressure_lld_sensitivity, + aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off, + swap_speed=swap_speed, + settling_time=settling_time, + capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off, + pressure_mad_supervision_on_off=pressure_mad_supervision_on_off, + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, + disable_volume_correction=disable_volume_correction, + ), ) async def dispense( @@ -582,160 +480,65 @@ async def dispense( recording_mode: int = 0, disable_volume_correction: Optional[List[bool]] = None, ): - """Dispense to (a) resource(s). - - See :meth:`pip_dispense` (the firmware command) for parameter documentation. This method serves - as a wrapper for that command, and will convert operations into the appropriate format. This - method additionally provides default values based on firmware instructions sent by Venus on - Vantage, rather than machine default values (which are often not what you want). - - Args: - ops: The aspiration operations. - use_channels: The channels to use. - hlcs: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be - determined automatically based on the tip and liquid used. - - jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for - determining the dispense mode. True for dispense mode 0 or 1. - blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for - all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware - documentation. True for dispense mode 1 or 3. - empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. - Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware - documentation. Dispense mode 4. - disable_volume_correction: Whether to disable volume correction for each operation. - """ - + """Deprecated: use ``VantagePIPBackend.dispense``.""" + # Legacy mix kwargs are not supported; raise early. if mix_volume is not None or mix_cycles is not None or mix_speed is not None: raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of " + "LiquidHandler.dispense instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - if jet is None: - jet = [False] * len(ops) - if empty is None: - empty = [False] * len(ops) - if blow_out is None: - blow_out = [False] * len(ops) - - if hlcs is None: - hlcs = [] - for j, bo, op in zip(jet, blow_out, ops): - hlcs.append( - get_vantage_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=j, - blow_out=bo, - ) - ) - - self._assert_valid_resources([op.resource for op in ops]) - - # correct volumes using the liquid class - disable_volume_correction = disable_volume_correction or [False] * len(ops) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) - ] - - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + new_ops = [ + NewDispense( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, + ) for op in ops ] - liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - # -1 compared to STAR? - lld_search_heights = lld_search_height or [ - wb - + op.resource.get_absolute_size_z() - + (2.7 - 1 if isinstance(op.resource, Well) else 5) # ? - for wb, op in zip(well_bottoms, ops) - ] - - flow_rates = [ - op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100) - for op, hlc in zip(ops, hlcs) - ] - - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0)) - for op, hlc in zip(ops, hlcs) - ] - - type_of_dispensing_mode = type_of_dispensing_mode or [ - _get_dispense_mode(jet=jet[i], empty=empty[i], blow_out=blow_out[i]) for i in range(len(ops)) - ] - - return await self.pip_dispense( - x_position=x_positions, - y_position=y_positions, - tip_pattern=channels_involved, - type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=[round(wb * 10) for wb in minimum_height or well_bottoms], - lld_search_height=[round(sh * 10) for sh in lld_search_heights], - liquid_surface_at_function_without_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - round(pod * 10) - for pod in pull_out_distance_to_take_transport_air_in_function_without_lld - or [5.0] * len(ops) - ], - immersion_depth=[round(id * 10) for id in immersion_depth or [0] * len(ops)], - surface_following_distance=[ - round(sfd * 10) for sfd in surface_following_distance or [2.1] * len(ops) - ], - tube_2nd_section_height_measured_from_zm=[ - round(t2sh * 10) for t2sh in tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ], - tube_2nd_section_ratio=[ - round(t2sr * 10) for t2sr in tube_2nd_section_ratio or [0] * len(ops) - ], - minimal_traverse_height_at_begin_of_command=[ - round(mth * 10) - for mth in minimal_traverse_height_at_begin_of_command - or [self._traversal_height] * len(ops) - ], - minimal_height_at_command_end=[ - round(mh * 10) - for mh in minimal_height_at_command_end or [self._traversal_height] * len(ops) - ], - dispense_volume=[round(vol * 100) for vol in volumes], - dispense_speed=[round(fr * 10) for fr in flow_rates], - cut_off_speed=[round(cs * 10) for cs in cut_off_speed or [250] * len(ops)], - stop_back_volume=[round(sbv * 100) for sbv in stop_back_volume or [0] * len(ops)], - transport_air_volume=[ - round(tav * 10) - for tav in transport_air_volume - or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ], - blow_out_air_volume=[round(boav * 100) for boav in blow_out_air_volumes], - lld_mode=lld_mode or [0] * len(ops), - side_touch_off_distance=round(side_touch_off_distance * 10), - dispense_position_above_z_touch_off=[ - round(dpz * 10) for dpz in dispense_position_above_z_touch_off or [0.5] * len(ops) - ], - lld_sensitivity=lld_sensitivity or [1] * len(ops), - pressure_lld_sensitivity=pressure_lld_sensitivity or [1] * len(ops), - swap_speed=[round(ss * 10) for ss in swap_speed or [1] * len(ops)], - settling_time=[round(st * 10) for st in settling_time or [0] * len(ops)], - mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], - mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], - mix_position_in_z_direction_from_liquid_surface=[ - round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) - ], - mix_speed=[round(op.mix.flow_rate * 100) if op.mix is not None else 10 for op in ops], - surface_following_distance_during_mixing=[ - round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) - ], - TODO_DD_2=TODO_DD_2 or [0] * len(ops), - tadm_algorithm_on_off=tadm_algorithm_on_off or 0, - limit_curve_index=limit_curve_index or [0] * len(ops), - recording_mode=recording_mode or 0, + # TODO_DD_2, mix_position_in_z_direction_from_liquid_surface, + # surface_following_distance_during_mixing have no BackendParams equivalent; dropped. + await self._vantage_pip.dispense( + new_ops, + use_channels, + backend_params=VantagePIPBackend.DispenseParams( + jet=jet, + blow_out=blow_out, + empty=empty, + hlcs=hlcs, + type_of_dispensing_mode=type_of_dispensing_mode, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + lld_search_height=lld_search_height, + minimum_height=minimum_height, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + pull_out_distance_to_take_transport_air_in_function_without_lld + ), + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, + tube_2nd_section_ratio=tube_2nd_section_ratio, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + transport_air_volume=transport_air_volume, + lld_mode=lld_mode, + side_touch_off_distance=side_touch_off_distance, + dispense_position_above_z_touch_off=dispense_position_above_z_touch_off, + lld_sensitivity=lld_sensitivity, + pressure_lld_sensitivity=pressure_lld_sensitivity, + swap_speed=swap_speed, + settling_time=settling_time, + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, + disable_volume_correction=disable_volume_correction, + ), ) async def pick_up_tips96( @@ -746,31 +549,15 @@ async def pick_up_tips96( minimal_traverse_height_at_begin_of_command: Optional[float] = None, minimal_height_at_command_end: Optional[float] = None, ): - # assert self.core96_head_installed, "96 head must be installed" - tip_spot_a1 = pickup.resource.get_item("A1") - prototypical_tip = None - for tip_spot in pickup.resource.get_all_items(): - if tip_spot.has_tip(): - prototypical_tip = tip_spot.get_tip() - break - if prototypical_tip is None: - raise ValueError("No tips found in the tip rack.") - assert isinstance(prototypical_tip, HamiltonTip), "Tip type must be HamiltonTip." - ttti = await self.get_or_assign_tip_type_index(prototypical_tip) - position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + pickup.offset - offset_z = pickup.offset.z - - return await self.core96_tip_pick_up( - x_position=round(position.x * 10), - y_position=round(position.y * 10), - tip_type=ttti, - tip_handling_method=tip_handling_method, - z_deposit_position=round((z_deposit_position + offset_z) * 10), - minimal_traverse_height_at_begin_of_command=round( - (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 - ), - minimal_height_at_command_end=round( - (minimal_height_at_command_end or self._traversal_height) * 10 + """Deprecated: use ``VantageHead96Backend.pick_up_tips96``.""" + new_pickup = NewPickupTipRack(resource=pickup.resource, offset=pickup.offset, tips=pickup.tips) + await self._vantage_head96.pick_up_tips96( + new_pickup, + backend_params=VantageHead96Backend.PickUpTipsParams( + tip_handling_method=tip_handling_method, + z_deposit_position=z_deposit_position, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, ), ) @@ -781,26 +568,14 @@ async def drop_tips96( minimal_traverse_height_at_begin_of_command: Optional[float] = None, minimal_height_at_command_end: Optional[float] = None, ): - # assert self.core96_head_installed, "96 head must be installed" - if isinstance(drop.resource, TipRack): - tip_spot_a1 = drop.resource.get_item("A1") - position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset - else: - raise NotImplementedError( - "Only TipRacks are supported for dropping tips on Vantage", - f"got {drop.resource}", - ) - offset_z = drop.offset.z - - return await self.core96_tip_discard( - x_position=round(position.x * 10), - y_position=round(position.y * 10), - z_deposit_position=round((z_deposit_position + offset_z) * 10), - minimal_traverse_height_at_begin_of_command=round( - (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 - ), - minimal_height_at_command_end=round( - (minimal_height_at_command_end or self._traversal_height) * 10 + """Deprecated: use ``VantageHead96Backend.drop_tips96``.""" + new_drop = NewDropTipRack(resource=drop.resource, offset=drop.offset) + await self._vantage_head96.drop_tips96( + new_drop, + backend_params=VantageHead96Backend.DropTipsParams( + z_deposit_position=z_deposit_position, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, ), ) @@ -836,130 +611,68 @@ async def aspirate96( recording_mode: int = 0, disable_volume_correction: bool = False, ): - """Aspirate from a plate. - - Args: - jet: Whether to find a liquid class with "jet" mode. Only used on dispense. - blow_out: Whether to find a liquid class with "blow out" mode. Only used on dispense. Note - that this is called "empty" in the VENUS liquid editor, but "blow out" in the firmware - documentation. - hlc: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be - determined automatically based on the tip and liquid used in the first well. - disable_volume_correction: Whether to disable volume correction. - """ - # assert self.core96_head_installed, "96 head must be installed" - + """Deprecated: use ``VantageHead96Backend.aspirate96``.""" + # Legacy mix kwargs are not supported; raise early. if mix_volume != 0 or mix_cycles != 0 or mix_speed != 0: raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead. " + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of " + "LiquidHandler.dispense96 instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) + # Convert legacy type to capability type. if isinstance(aspiration, MultiHeadAspirationPlate): - plate = aspiration.wells[0].parent - assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" - rot = plate.get_absolute_rotation() - if rot.x % 360 != 0 or rot.y % 360 != 0: - raise ValueError("Plate rotation around x or y is not supported for 96 head operations") - if rot.z % 360 == 180: - ref_well = plate.get_well("H12") - elif rot.z % 360 == 0: - ref_well = plate.get_well("A1") - else: - raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") - position = ( - ref_well.get_location_wrt(self.deck) - + ref_well.center() - + aspiration.offset - + Coordinate(z=ref_well.material_z_thickness) + new_asp = NewMultiHeadAspirationPlate( + wells=aspiration.wells, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=aspiration.mix, ) - # -1 compared to STAR? - well_bottoms = position.z - lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 else: - x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them - y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them - x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 - y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width - position = ( - aspiration.container.get_location_wrt(self.deck, z="cavity_bottom") - + Coordinate(x=x_position, y=y_position) - + aspiration.offset + new_asp = NewMultiHeadAspirationContainer( + container=aspiration.container, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=aspiration.mix, ) - bottom = position.z - lld_search_height = bottom + aspiration.container.get_absolute_size_z() + 2.7 - 1 - - liquid_height = position.z + (aspiration.liquid_height or 0) - - tip = next(tip for tip in aspiration.tips if tip is not None) - if hlc is None: - hlc = get_vantage_liquid_class( - tip_volume=tip.maximal_volume, - is_core=True, - is_tip=True, - has_filter=tip.has_filter, - liquid=Liquid.WATER, # default to WATER + + await self._vantage_head96.aspirate96( + new_asp, + backend_params=VantageHead96Backend.AspirateParams( jet=jet, blow_out=blow_out, - ) - - if disable_volume_correction or hlc is None: - volume = aspiration.volume - else: # hlc is not None and not disable_volume_correction - volume = hlc.compute_corrected_volume(aspiration.volume) - - transport_air_volume = transport_air_volume or ( - hlc.aspiration_air_transport_volume if hlc is not None else 0 - ) - blow_out_air_volume = blow_out_air_volume or ( - hlc.aspiration_blow_out_volume if hlc is not None else 0 - ) - flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) - swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) - settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 5) - - return await self.core96_aspiration_of_liquid( - x_position=round(position.x * 10), - y_position=round(position.y * 10), - type_of_aspiration=type_of_aspiration, - minimal_traverse_height_at_begin_of_command=round( - (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 - ), - minimal_height_at_command_end=round( - minimal_height_at_command_end or self._traversal_height * 10 - ), - lld_search_height=round(lld_search_height * 10), - liquid_surface_at_function_without_lld=round(liquid_height * 10), - pull_out_distance_to_take_transport_air_in_function_without_lld=round( - pull_out_distance_to_take_transport_air_in_function_without_lld * 10 - ), - minimum_height=round(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm * 10), - tube_2nd_section_ratio=round(tube_2nd_section_ratio * 10), - immersion_depth=round(immersion_depth * 10), - surface_following_distance=round(surface_following_distance * 10), - aspiration_volume=round(volume * 100), - aspiration_speed=round(flow_rate * 10), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 100), - pre_wetting_volume=round(pre_wetting_volume * 100), - lld_mode=lld_mode, - lld_sensitivity=lld_sensitivity, - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mix_volume=round(aspiration.mix.volume * 100) if aspiration.mix is not None else 0, - mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, - mix_position_in_z_direction_from_liquid_surface=round( - mix_position_in_z_direction_from_liquid_surface * 100 - ), - surface_following_distance_during_mixing=round( - surface_following_distance_during_mixing * 100 + hlc=hlc, + type_of_aspiration=type_of_aspiration, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + pull_out_distance_to_take_transport_air_in_function_without_lld + ), + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, + tube_2nd_section_ratio=tube_2nd_section_ratio, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + transport_air_volume=transport_air_volume, + blow_out_air_volume=blow_out_air_volume, + pre_wetting_volume=pre_wetting_volume, + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + swap_speed=swap_speed, + settling_time=settling_time, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, + disable_volume_correction=disable_volume_correction, ), - mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 20, - limit_curve_index=limit_curve_index, - tadm_channel_pattern=tadm_channel_pattern, - tadm_algorithm_on_off=tadm_algorithm_on_off, - recording_mode=recording_mode, ) async def dispense96( @@ -997,136 +710,71 @@ async def dispense96( recording_mode: int = 0, disable_volume_correction: bool = False, ): - """Dispense to a plate using the 96 head. - - Args: - jet: whether to dispense in jet mode. - blow_out: whether to dispense in jet mode. In the VENUS liquid editor, this is called "empty". - Dispensing mode 1 or 3. - empty: whether to truly empty the tip. This does not exist in the liquid editor, but is in the - firmware documentation. Dispense mode 4. - liquid_class: the liquid class to use. If not provided, it will be determined based on the - liquid in the first well. - - type_of_dispensing_mode: the type of dispense mode to use. If not provided, it will be - determined based on the jet, blow_out, and empty parameters. - disable_volume_correction: Whether to disable volume correction. - """ - + """Deprecated: use ``VantageHead96Backend.dispense96``.""" + # Legacy mix kwargs are not supported; raise early. if mix_volume != 0 or mix_cycles != 0 or mix_speed is not None: raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead. " + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of " + "LiquidHandler.dispense96 instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) + # Convert legacy type to capability type. if isinstance(dispense, MultiHeadDispensePlate): - plate = dispense.wells[0].parent - assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" - rot = plate.get_absolute_rotation() - if rot.x % 360 != 0 or rot.y % 360 != 0: - raise ValueError("Plate rotation around x or y is not supported for 96 head operations") - if rot.z % 360 == 180: - ref_well = plate.get_well("H12") - elif rot.z % 360 == 0: - ref_well = plate.get_well("A1") - else: - raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") - position = ( - ref_well.get_location_wrt(self.deck) - + ref_well.center() - + dispense.offset - + Coordinate(z=ref_well.material_z_thickness) + new_disp = NewMultiHeadDispensePlate( + wells=dispense.wells, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=dispense.mix, ) - # -1 compared to STAR? - well_bottoms = position.z - lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 else: - x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them - y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them - x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 - y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width - position = ( - dispense.container.get_location_wrt(self.deck, z="cavity_bottom") - + Coordinate(x=x_position, y=y_position) - + dispense.offset + new_disp = NewMultiHeadDispenseContainer( + container=dispense.container, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=dispense.mix, ) - bottom = position.z - lld_search_height = bottom + dispense.container.get_absolute_size_z() + 2.7 - 1 - - liquid_height = position.z + (dispense.liquid_height or 0) + 10 - - tip = next(tip for tip in dispense.tips if tip is not None) - if hlc is None: - hlc = get_vantage_liquid_class( - tip_volume=tip.maximal_volume, - is_core=True, - is_tip=True, - has_filter=tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=jet, - blow_out=blow_out, # see method docstring - ) - - if disable_volume_correction or hlc is None: - volume = dispense.volume - else: # hlc is not None and not disable_volume_correction - volume = hlc.compute_corrected_volume(dispense.volume) - - transport_air_volume = transport_air_volume or ( - hlc.dispense_air_transport_volume if hlc is not None else 0 - ) - blow_out_air_volume = blow_out_air_volume or ( - hlc.dispense_blow_out_volume if hlc is not None else 0 - ) - flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) - swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) - settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) - type_of_dispensing_mode = type_of_dispensing_mode or _get_dispense_mode( - jet=jet, empty=empty, blow_out=blow_out - ) - return await self.core96_dispensing_of_liquid( - x_position=round(position.x * 10), - y_position=round(position.y * 10), - type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=round(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm * 10), - tube_2nd_section_ratio=round(tube_2nd_section_ratio * 10), - lld_search_height=round(lld_search_height * 10), - liquid_surface_at_function_without_lld=round(liquid_height * 10), - pull_out_distance_to_take_transport_air_in_function_without_lld=round( - pull_out_distance_to_take_transport_air_in_function_without_lld * 10 - ), - immersion_depth=round(immersion_depth * 10), - surface_following_distance=round(surface_following_distance * 10), - minimal_traverse_height_at_begin_of_command=round( - (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 - ), - minimal_height_at_command_end=round( - (minimal_height_at_command_end or self._traversal_height) * 10 - ), - dispense_volume=round(volume * 100), - dispense_speed=round(flow_rate * 10), - cut_off_speed=round(cut_off_speed * 10), - stop_back_volume=round(stop_back_volume * 100), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 100), - lld_mode=lld_mode, - lld_sensitivity=lld_sensitivity, - side_touch_off_distance=round(side_touch_off_distance * 10), - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mix_volume=round(dispense.mix.volume * 100) if dispense.mix is not None else 0, - mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, - mix_position_in_z_direction_from_liquid_surface=round( - mix_position_in_z_direction_from_liquid_surface * 10 + await self._vantage_head96.dispense96( + new_disp, + backend_params=VantageHead96Backend.DispenseParams( + jet=jet, + blow_out=blow_out, + empty=empty, + hlc=hlc, + type_of_dispensing_mode=type_of_dispensing_mode, + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, + tube_2nd_section_ratio=tube_2nd_section_ratio, + pull_out_distance_to_take_transport_air_in_function_without_lld=( + pull_out_distance_to_take_transport_air_in_function_without_lld + ), + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command, + minimal_height_at_command_end=minimal_height_at_command_end, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + transport_air_volume=transport_air_volume, + blow_out_air_volume=blow_out_air_volume, + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + side_touch_off_distance=side_touch_off_distance, + swap_speed=swap_speed, + settling_time=settling_time, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, + disable_volume_correction=disable_volume_correction, ), - surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), - mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 10, - limit_curve_index=limit_curve_index, - tadm_channel_pattern=tadm_channel_pattern, - tadm_algorithm_on_off=tadm_algorithm_on_off, - recording_mode=recording_mode, ) async def pick_up_resource( @@ -1139,37 +787,53 @@ async def pick_up_resource( hotel_depth: float = 0, minimal_height_at_command_end: float = 284.0, ): - """Pick up a resource with the IPG. You probably want to use :meth:`move_resource`, which - allows you to pick up and move a resource with a single command.""" - - center = pickup.resource.get_location_wrt(self.deck, x="c", y="c", z="b") + pickup.offset + """Deprecated: use ``IPGBackend.pick_up_at_location``.""" + center = pickup.resource.get_absolute_location(x="c", y="c", z="b") + pickup.offset grip_height = center.z + pickup.resource.get_absolute_size_z() - pickup.pickup_distance_from_top - plate_width = pickup.resource.get_absolute_size_x() - - await self.ipg_grip_plate( - x_position=round(center.x * 10), - y_position=round(center.y * 10), - z_position=round(grip_height * 10), - grip_strength=grip_strength, - open_gripper_position=round(plate_width * 10) + 32, - plate_width=round(plate_width * 10) - 33, - plate_width_tolerance=round(plate_width_tolerance * 10), - acceleration_index=acceleration_index, - z_clearance_height=round(z_clearance_height * 10), - hotel_depth=round(hotel_depth * 10), - minimal_height_at_command_end=round( - (minimal_height_at_command_end or self._traversal_height) * 10 + resource_width = pickup.resource.get_absolute_size_x() + + direction_map = { + GripDirection.FRONT: 0, + GripDirection.RIGHT: 90, + GripDirection.BACK: 180, + GripDirection.LEFT: 270, + } + direction = direction_map[pickup.direction] + + await self._vantage_ipg.pick_up_at_location( + location=Coordinate(x=center.x, y=center.y, z=grip_height), + direction=direction, + resource_width=resource_width, + backend_params=IPGBackend.PickUpParams( + grip_strength=grip_strength, + plate_width_tolerance=plate_width_tolerance, + acceleration_index=acceleration_index, + z_clearance_height=z_clearance_height, + hotel_depth=hotel_depth, + minimal_height_at_command_end=minimal_height_at_command_end, ), ) async def move_picked_up_resource(self, move: ResourceMove): - """Move a resource picked up with the IPG. See :meth:`pick_up_resource`. + """Deprecated: use ``IPGBackend.move_to_location``. You probably want to use :meth:`move_resource`, which allows you to pick up and move a resource with a single command. """ - - raise NotImplementedError() + grip_height = ( + move.location.z + + move.resource.get_absolute_size_z() + - move.pickup_distance_from_top + + move.offset.z + ) + await self._vantage_ipg.move_to_location( + location=Coordinate( + x=move.location.x + move.offset.x, + y=move.location.y + move.offset.y, + z=grip_height, + ), + direction=0, # direction not used for movement + ) async def drop_resource( self, @@ -1179,55 +843,50 @@ async def drop_resource( hotel_depth: float = 0, minimal_height_at_command_end: float = 284.0, ): - """Release a resource picked up with the IPG. See :meth:`pick_up_resource`. - - You probably want to use :meth:`move_resource`, which allows you to pick up and move a resource - with a single command. - """ - + """Deprecated: use ``IPGBackend.drop_at_location``.""" center = drop.destination + drop.resource.center() + drop.offset grip_height = center.z + drop.resource.get_absolute_size_z() - drop.pickup_distance_from_top - plate_width = drop.resource.get_absolute_size_x() - - await self.ipg_put_plate( - x_position=round(center.x * 10), - y_position=round(center.y * 10), - z_position=round(grip_height * 10), - z_clearance_height=round(z_clearance_height * 10), - open_gripper_position=round(plate_width * 10) + 32, - press_on_distance=press_on_distance, - hotel_depth=round(hotel_depth * 10), - minimal_height_at_command_end=round( - (minimal_height_at_command_end or self._traversal_height) * 10 + resource_width = drop.resource.get_absolute_size_x() + + direction_map = { + GripDirection.FRONT: 0, + GripDirection.RIGHT: 90, + GripDirection.BACK: 180, + GripDirection.LEFT: 270, + } + direction = direction_map[drop.direction] + + await self._vantage_ipg.drop_at_location( + location=Coordinate(x=center.x, y=center.y, z=grip_height), + direction=direction, + resource_width=resource_width, + backend_params=IPGBackend.DropParams( + z_clearance_height=z_clearance_height, + press_on_distance=press_on_distance / 10, + hotel_depth=hotel_depth, + minimal_height_at_command_end=minimal_height_at_command_end, ), ) async def prepare_for_manual_channel_operation(self, channel: int): - """Prepare the robot for manual operation.""" - - return await self.expose_channel_n(channel_index=channel + 1) # ? + """Deprecated: use ``vantage.driver.pip.expose_channel_n()``.""" + return await self.expose_channel_n(channel_index=channel + 1) async def move_channel_x(self, channel: int, x: float): - """Move the specified channel to the specified x coordinate.""" - - return await self.x_arm_move_to_x_position(round(x * 10)) + """Deprecated: use ``vantage.driver.x_arm.move_to()``.""" + return await self._vantage_x_arm.move_to(x) async def move_channel_y(self, channel: int, y: float): - """Move the specified channel to the specified y coordinate.""" - - return await self.position_single_channel_in_y_direction(channel + 1, round(y * 10)) + """Deprecated: use ``vantage.driver.pip.position_single_channel_in_y_direction()``.""" + return await self._vantage_pip.position_single_channel_in_y_direction(channel + 1, y) async def move_channel_z(self, channel: int, z: float): - """Move the specified channel to the specified z coordinate.""" - - return await self.position_single_channel_in_z_direction(channel + 1, round(z * 10)) + """Deprecated: use ``vantage.driver.pip.position_single_channel_in_z_direction()``.""" + return await self._vantage_pip.position_single_channel_in_z_direction(channel + 1, z) def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - if not isinstance(tip, HamiltonTip): - return False - if tip.tip_size in {TipSize.XL}: - return False - return True + """Deprecated: use ``VantagePIPBackend.can_pick_up_tip``.""" + return self._vantage_pip.can_pick_up_tip(channel_idx, tip) # ============== Firmware Commands ============== @@ -1242,97 +901,36 @@ async def set_led_color( uv: int, blink_interval: Optional[int] = None, ): - """Set the LED color. - - Args: - mode: The mode of the LED. One of "on", "off", or "blink". - intensity: The intensity of the LED. 0-100. - white: The white color of the LED. 0-100. - red: The red color of the LED. 0-100. - green: The green color of the LED. 0-100. - blue: The blue color of the LED. 0-100. - uv: The UV color of the LED. 0-100. - blink_interval: The blink interval in ms. Only used if mode is "blink". - """ - - if blink_interval is not None: - if mode != "blink": - raise ValueError("blink_interval is only used when mode is 'blink'.") - - return await self.send_command( - module="C0AM", - command="LI", - li={ - "on": 1, - "off": 0, - "blink": 2, - }[mode], - os=intensity, - ok=blink_interval or 750, # default non zero value - ol=f"{white} {red} {green} {blue} {uv}", + """Deprecated: use ``VantageDriver.set_led_color``.""" + return await self.driver.set_led_color( + mode, intensity, white, red, green, blue, uv, blink_interval ) async def set_loading_cover(self, cover_open: bool): - """Set the loading cover. - - Args: - cover_open: Whether the cover should be open or closed. - """ - - return await self.send_command(module="I1AM", command="LP", lp=not cover_open) + """Deprecated: use ``VantageLoadingCover.set_cover``.""" + return await self._vantage_loading_cover.set_cover(cover_open) async def loading_cover_request_initialization_status(self) -> bool: - """Request the loading cover initialization status. - - This command was based on the STAR command (QW) and the VStarTranslator log. - - Returns: - True if the cover module is initialized, False otherwise. - """ - - resp = await self.send_command(module="I1AM", command="QW", fmt={"qw": "int"}) - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``VantageLoadingCover.request_initialization_status``.""" + return await self._vantage_loading_cover.request_initialization_status() async def loading_cover_initialize(self): - """Initialize the loading cover.""" - - return await self.send_command( - module="I1AM", - command="MI", - ) + """Deprecated: use ``VantageLoadingCover.initialize``.""" + return await self._vantage_loading_cover.initialize() async def arm_request_instrument_initialization_status( self, ) -> bool: - """Request the instrument initialization status. - - This command was based on the STAR command (QW) and the VStarTranslator log. A1AM corresponds - to "arm". - - Returns: - True if the arm module is initialized, False otherwise. - """ - - resp = await self.send_command(module="A1AM", command="QW", fmt={"qw": "int"}) - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``VantageDriver.arm_request_instrument_initialization_status``.""" + return await self.driver.arm_request_instrument_initialization_status() async def arm_pre_initialize(self): - """Initialize the arm module.""" - - return await self.send_command(module="A1AM", command="MI") + """Deprecated: use ``VantageDriver.arm_pre_initialize``.""" + return await self.driver.arm_pre_initialize() async def pip_request_initialization_status(self) -> bool: - """Request the pip initialization status. - - This command was based on the STAR command (QW) and the VStarTranslator log. A1PM corresponds - to all pip channels together. - - Returns: - True if the pip channels module is initialized, False otherwise. - """ - - resp = await self.send_command(module="A1PM", command="QW", fmt={"qw": "int"}) - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``VantageDriver.pip_request_initialization_status``.""" + return await self.driver.pip_request_initialization_status() async def pip_initialize( self, @@ -1345,65 +943,24 @@ async def pip_initialize( tip_type: Optional[List[int]] = None, TODO_DI_2: int = 0, ): - """Initialize - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - begin_z_deposit_position: Begin of tip deposit process (Z- discard range) [0.1mm] ?? - end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - tip_type: Tip type (see command TT). - TODO_DI_2: Unknown. - """ - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - - if begin_z_deposit_position is None: - begin_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): - raise ValueError("begin_z_deposit_position must be in range 0 to 3600") - - if end_z_deposit_position is None: - end_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in end_z_deposit_position): - raise ValueError("end_z_deposit_position must be in range 0 to 3600") - - if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - if tip_pattern is None: - tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if tip_type is None: - tip_type = [4] * self.num_channels - elif not all(0 <= x <= 199 for x in tip_type): - raise ValueError("tip_type must be in range 0 to 199") - - if not -1000 <= TODO_DI_2 <= 1000: - raise ValueError("TODO_DI_2 must be in range -1000 to 1000") - - return await self.send_command( - module="A1PM", - command="DI", - xp=x_position, - yp=y_position, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - te=minimal_height_at_command_end, - tm=tip_pattern, - tt=tip_type, + """Deprecated: use ``VantageDriver.pip_initialize``. + + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. + """ + return await self.driver.pip_initialize( + x_position=[v / 10 for v in x_position], + y_position=[v / 10 for v in y_position], + begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position] + if begin_z_deposit_position is not None + else None, + end_z_deposit_position=[v / 10 for v in end_z_deposit_position] + if end_z_deposit_position is not None + else None, + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end] + if minimal_height_at_command_end is not None + else None, + tip_pattern=tip_pattern, + tip_type=tip_type, ts=TODO_DI_2, ) @@ -1416,43 +973,9 @@ async def define_tip_needle( tip_size: TipSize, pickup_method: TipPickupMethod, ): - """Tip/needle definition. - - Args: - tip_type_table_index: tip_table_index - filter: with(out) filter - tip_length: Tip length [0.1mm] - maximum_tip_volume: Maximum volume of tip [0.1ul] Note! it's automatically limited to max. - channel capacity - tip_type: Type of tip collar (Tip type identification) - pickup_method: pick up method. Attention! The values set here are temporary and apply only - until power OFF or RESET. After power ON the default values apply. (see Table 3) - """ - - if not 0 <= tip_type_table_index <= 99: - raise ValueError( - f"tip_type_table_index must be between 0 and 99, but is {tip_type_table_index}" - ) - if not 0 <= tip_type_table_index <= 99: - raise ValueError( - f"tip_type_table_index must be between 0 and 99, but is {tip_type_table_index}" - ) - if not 1 <= tip_length <= 1999: - raise ValueError(f"tip_length must be between 1 and 1999, but is {tip_length}") - if not 1 <= maximum_tip_volume <= 56000: - raise ValueError( - f"maximum_tip_volume must be between 1 and 56000, but is {maximum_tip_volume}" - ) - - return await self.send_command( - module="A1AM", - command="TT", - ti=f"{tip_type_table_index:02}", - tf=has_filter, - tl=f"{tip_length:04}", - tv=f"{maximum_tip_volume:05}", - tg=tip_size.value, - tu=pickup_method.value, + """Deprecated: use ``VantageDriver.define_tip_needle``.""" + return await self.driver.define_tip_needle( + tip_type_table_index, has_filter, tip_length, maximum_tip_volume, tip_size, pickup_method ) async def pip_aspirate( @@ -1497,288 +1020,121 @@ async def pip_aspirate( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Aspiration of liquid - - Args: - type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - lld_search_height: LLD search height [0.1mm]. - clot_detection_height: (0). - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - pull_out_distance_to_take_transport_air_in_function_without_lld: - Pull out distance to take transp. air in function without LLD [0.1mm]. - tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. - tube_2nd_section_ratio: Tube 2nd section ratio. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - immersion_depth: Immersion depth [0.1mm]. - surface_following_distance: Surface following distance [0.1mm]. - aspiration_volume: Aspiration volume [0.01ul]. - TODO_DA_2: (0). - aspiration_speed: Aspiration speed [0.1ul]/s. - transport_air_volume: Transport air volume [0.1ul]. - blow_out_air_volume: Blow out air volume [0.01ul]. - pre_wetting_volume: Pre wetting volume [0.1ul]. - lld_mode: LLD Mode (0 = off). - lld_sensitivity: LLD sensitivity (1 = high, 4 = low). - pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). - aspirate_position_above_z_touch_off: (0). - TODO_DA_4: (0). - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - settling_time: Settling time [0.1s]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid - surface[0.1mm]. - mix_speed: Mix speed [0.1ul/s]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - TODO_DA_5: (0). - capacitive_mad_supervision_on_off: Capacitive MAD supervision on/off (0 = OFF). - pressure_mad_supervision_on_off: Pressure MAD supervision on/off (0 = OFF). - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - limit_curve_index: Limit curve index. - recording_mode: Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements). - """ + """Deprecated: use ``VantagePIPBackend._pip_aspirate``.""" if type_of_aspiration is None: type_of_aspiration = [0] * self.num_channels - elif not all(0 <= x <= 2 for x in type_of_aspiration): - raise ValueError("type_of_aspiration must be in range 0 to 2") - if tip_pattern is None: tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - if lld_search_height is None: lld_search_height = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in lld_search_height): - raise ValueError("lld_search_height must be in range 0 to 3600") - if clot_detection_height is None: clot_detection_height = [60] * self.num_channels - elif not all(0 <= x <= 500 for x in clot_detection_height): - raise ValueError("clot_detection_height must be in range 0 to 500") - if liquid_surface_at_function_without_lld is None: liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - if pull_out_distance_to_take_transport_air_in_function_without_lld is None: pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels - elif not all( - 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld - ): - raise ValueError( - "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 3600" - ) - if tube_2nd_section_height_measured_from_zm is None: tube_2nd_section_height_measured_from_zm = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): - raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") - if tube_2nd_section_ratio is None: tube_2nd_section_ratio = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): - raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") - if minimum_height is None: minimum_height = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimum_height): - raise ValueError("minimum_height must be in range 0 to 3600") - if immersion_depth is None: immersion_depth = [0] * self.num_channels - elif not all(-3600 <= x <= 3600 for x in immersion_depth): - raise ValueError("immersion_depth must be in range -3600 to 3600") - if surface_following_distance is None: surface_following_distance = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance): - raise ValueError("surface_following_distance must be in range 0 to 3600") - if aspiration_volume is None: aspiration_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in aspiration_volume): - raise ValueError("aspiration_volume must be in range 0 to 125000") - - if TODO_DA_2 is None: - TODO_DA_2 = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in TODO_DA_2): - raise ValueError("TODO_DA_2 must be in range 0 to 125000") - if aspiration_speed is None: aspiration_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in aspiration_speed): - raise ValueError("aspiration_speed must be in range 10 to 10000") - if transport_air_volume is None: transport_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 500 for x in transport_air_volume): - raise ValueError("transport_air_volume must be in range 0 to 500") - if blow_out_air_volume is None: blow_out_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in blow_out_air_volume): - raise ValueError("blow_out_air_volume must be in range 0 to 125000") - if pre_wetting_volume is None: pre_wetting_volume = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in pre_wetting_volume): - raise ValueError("pre_wetting_volume must be in range 0 to 999") - if lld_mode is None: lld_mode = [1] * self.num_channels - elif not all(0 <= x <= 4 for x in lld_mode): - raise ValueError("lld_mode must be in range 0 to 4") - if lld_sensitivity is None: lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in lld_sensitivity): - raise ValueError("lld_sensitivity must be in range 1 to 4") - if pressure_lld_sensitivity is None: pressure_lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): - raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") - if aspirate_position_above_z_touch_off is None: aspirate_position_above_z_touch_off = [5] * self.num_channels - elif not all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off): - raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 100") - - if TODO_DA_4 is None: - TODO_DA_4 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DA_4): - raise ValueError("TODO_DA_4 must be in range 0 to 1") - if swap_speed is None: swap_speed = [100] * self.num_channels - elif not all(3 <= x <= 1600 for x in swap_speed): - raise ValueError("swap_speed must be in range 3 to 1600") - if settling_time is None: settling_time = [5] * self.num_channels - elif not all(0 <= x <= 99 for x in settling_time): - raise ValueError("settling_time must be in range 0 to 99") - if mix_volume is None: mix_volume = [0] * self.num_channels - elif not all(0 <= x <= 12500 for x in mix_volume): - raise ValueError("mix_volume must be in range 0 to 12500") - if mix_cycles is None: mix_cycles = [0] * self.num_channels - elif not all(0 <= x <= 99 for x in mix_cycles): - raise ValueError("mix_cycles must be in range 0 to 99") - if mix_position_in_z_direction_from_liquid_surface is None: mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels - elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): - raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") - if mix_speed is None: mix_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in mix_speed): - raise ValueError("mix_speed must be in range 10 to 10000") - if surface_following_distance_during_mixing is None: surface_following_distance_during_mixing = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") - if TODO_DA_5 is None: TODO_DA_5 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DA_5): - raise ValueError("TODO_DA_5 must be in range 0 to 1") - if capacitive_mad_supervision_on_off is None: capacitive_mad_supervision_on_off = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): - raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") - if pressure_mad_supervision_on_off is None: pressure_mad_supervision_on_off = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): - raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - if limit_curve_index is None: limit_curve_index = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in limit_curve_index): - raise ValueError("limit_curve_index must be in range 0 to 999") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1PM", - command="DA", - at=type_of_aspiration, - tm=tip_pattern, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - lp=lld_search_height, - ch=clot_detection_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - zx=minimum_height, - ip=immersion_depth, - fp=surface_following_distance, - av=aspiration_volume, - # ar=TODO_DA_2, # this parameters is not used by VoV - as_=aspiration_speed, - ta=transport_air_volume, - ba=blow_out_air_volume, - oa=pre_wetting_volume, - lm=lld_mode, - ll=lld_sensitivity, - lv=pressure_lld_sensitivity, - zo=aspirate_position_above_z_touch_off, - # lg=TODO_DA_4, - de=swap_speed, - wt=settling_time, - mv=mix_volume, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - ms=mix_speed, - mh=surface_following_distance_during_mixing, - la=TODO_DA_5, - lb=capacitive_mad_supervision_on_off, - lc=pressure_mad_supervision_on_off, - gj=tadm_algorithm_on_off, - gi=limit_curve_index, - gk=recording_mode, + + return await self._vantage_pip._pip_aspirate( + x_position=x_position, + y_position=y_position, + type_of_aspiration=type_of_aspiration, + tip_pattern=tip_pattern, + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + lld_search_height=[v / 10 for v in lld_search_height], + clot_detection_height=[v / 10 for v in clot_detection_height], + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + v / 10 for v in pull_out_distance_to_take_transport_air_in_function_without_lld + ], + tube_2nd_section_height_measured_from_zm=[ + v / 10 for v in tube_2nd_section_height_measured_from_zm + ], + tube_2nd_section_ratio=[v / 10 for v in tube_2nd_section_ratio], + minimum_height=[v / 10 for v in minimum_height], + immersion_depth=[v / 10 for v in immersion_depth], + surface_following_distance=[v / 10 for v in surface_following_distance], + aspiration_volume=[v / 100 for v in aspiration_volume], + aspiration_speed=[v / 10 for v in aspiration_speed], + transport_air_volume=[v / 10 for v in transport_air_volume], + blow_out_air_volume=[v / 100 for v in blow_out_air_volume], + pre_wetting_volume=[v / 100 for v in pre_wetting_volume], + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + pressure_lld_sensitivity=pressure_lld_sensitivity, + aspirate_position_above_z_touch_off=[v / 10 for v in aspirate_position_above_z_touch_off], + swap_speed=[v / 10 for v in swap_speed], + settling_time=[v / 10 for v in settling_time], + mix_volume=[v / 100 for v in mix_volume], + mix_cycles=mix_cycles, + mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, + mix_speed=[v / 10 for v in mix_speed], + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off, + pressure_mad_supervision_on_off=pressure_mad_supervision_on_off, + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, + TODO_DA_5=TODO_DA_5, ) async def pip_dispense( @@ -1820,269 +1176,116 @@ async def pip_dispense( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Dispensing of liquid - - Args: - type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at - surface 3 = Blow at surface 4 = Empty. - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - lld_search_height: LLD search height [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - pull_out_distance_to_take_transport_air_in_function_without_lld: - Pull out distance to take transp. air in function without LLD [0.1mm] - . - immersion_depth: Immersion depth [0.1mm]. - surface_following_distance: Surface following distance [0.1mm]. - tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. - tube_2nd_section_ratio: Tube 2nd section ratio. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - dispense_volume: Dispense volume [0.01ul]. - dispense_speed: Dispense speed [0.1ul/s]. - cut_off_speed: Cut off speed [0.1ul/s]. - stop_back_volume: Stop back volume [0.1ul]. - transport_air_volume: Transport air volume [0.1ul]. - blow_out_air_volume: Blow out air volume [0.01ul]. - lld_mode: LLD Mode (0 = off). - side_touch_off_distance: Side touch off distance [0.1mm]. - dispense_position_above_z_touch_off: (0). - lld_sensitivity: LLD sensitivity (1 = high, 4 = low). - pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - settling_time: Settling time [0.1s]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid - surface[0.1mm]. - mix_speed: Mix speed [0.1ul/s]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - TODO_DD_2: (0). - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - limit_curve_index: Limit curve index. - recording_mode: - Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) - . - """ + """Deprecated: use ``VantagePIPBackend._pip_dispense``.""" if type_of_dispensing_mode is None: type_of_dispensing_mode = [0] * self.num_channels - elif not all(0 <= x <= 4 for x in type_of_dispensing_mode): - raise ValueError("type_of_dispensing_mode must be in range 0 to 4") - if tip_pattern is None: tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - if minimum_height is None: minimum_height = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimum_height): - raise ValueError("minimum_height must be in range 0 to 3600") - if lld_search_height is None: lld_search_height = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in lld_search_height): - raise ValueError("lld_search_height must be in range 0 to 3600") - if liquid_surface_at_function_without_lld is None: liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - if pull_out_distance_to_take_transport_air_in_function_without_lld is None: pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels - elif not all( - 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld - ): - raise ValueError( - "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 3600" - ) - if immersion_depth is None: immersion_depth = [0] * self.num_channels - elif not all(-3600 <= x <= 3600 for x in immersion_depth): - raise ValueError("immersion_depth must be in range -3600 to 3600") - if surface_following_distance is None: surface_following_distance = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance): - raise ValueError("surface_following_distance must be in range 0 to 3600") - if tube_2nd_section_height_measured_from_zm is None: tube_2nd_section_height_measured_from_zm = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): - raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") - if tube_2nd_section_ratio is None: tube_2nd_section_ratio = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): - raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - if dispense_volume is None: dispense_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in dispense_volume): - raise ValueError("dispense_volume must be in range 0 to 125000") - if dispense_speed is None: dispense_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in dispense_speed): - raise ValueError("dispense_speed must be in range 10 to 10000") - if cut_off_speed is None: cut_off_speed = [250] * self.num_channels - elif not all(10 <= x <= 10000 for x in cut_off_speed): - raise ValueError("cut_off_speed must be in range 10 to 10000") - if stop_back_volume is None: stop_back_volume = [0] * self.num_channels - elif not all(0 <= x <= 180 for x in stop_back_volume): - raise ValueError("stop_back_volume must be in range 0 to 180") - if transport_air_volume is None: transport_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 500 for x in transport_air_volume): - raise ValueError("transport_air_volume must be in range 0 to 500") - if blow_out_air_volume is None: blow_out_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in blow_out_air_volume): - raise ValueError("blow_out_air_volume must be in range 0 to 125000") - if lld_mode is None: lld_mode = [1] * self.num_channels - elif not all(0 <= x <= 4 for x in lld_mode): - raise ValueError("lld_mode must be in range 0 to 4") - - if not 0 <= side_touch_off_distance <= 45: - raise ValueError("side_touch_off_distance must be in range 0 to 45") - if dispense_position_above_z_touch_off is None: dispense_position_above_z_touch_off = [5] * self.num_channels - elif not all(0 <= x <= 100 for x in dispense_position_above_z_touch_off): - raise ValueError("dispense_position_above_z_touch_off must be in range 0 to 100") - if lld_sensitivity is None: lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in lld_sensitivity): - raise ValueError("lld_sensitivity must be in range 1 to 4") - if pressure_lld_sensitivity is None: pressure_lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): - raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") - if swap_speed is None: swap_speed = [100] * self.num_channels - elif not all(3 <= x <= 1600 for x in swap_speed): - raise ValueError("swap_speed must be in range 3 to 1600") - if settling_time is None: settling_time = [5] * self.num_channels - elif not all(0 <= x <= 99 for x in settling_time): - raise ValueError("settling_time must be in range 0 to 99") - if mix_volume is None: mix_volume = [0] * self.num_channels - elif not all(0 <= x <= 12500 for x in mix_volume): - raise ValueError("mix_volume must be in range 0 to 12500") - if mix_cycles is None: mix_cycles = [0] * self.num_channels - elif not all(0 <= x <= 99 for x in mix_cycles): - raise ValueError("mix_cycles must be in range 0 to 99") - if mix_position_in_z_direction_from_liquid_surface is None: mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels - elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): - raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") - if mix_speed is None: mix_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in mix_speed): - raise ValueError("mix_speed must be in range 10 to 10000") - if surface_following_distance_during_mixing is None: surface_following_distance_during_mixing = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") - if TODO_DD_2 is None: TODO_DD_2 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DD_2): - raise ValueError("TODO_DD_2 must be in range 0 to 1") - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - if limit_curve_index is None: limit_curve_index = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in limit_curve_index): - raise ValueError("limit_curve_index must be in range 0 to 999") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1PM", - command="DD", - dm=type_of_dispensing_mode, - tm=tip_pattern, - xp=x_position, - yp=y_position, - zx=minimum_height, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - ip=immersion_depth, - fp=surface_following_distance, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - dv=[f"{vol:04}" for vol in dispense_volume], # it appears at least 4 digits are needed - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - ba=blow_out_air_volume, - lm=lld_mode, - dj=side_touch_off_distance, - zo=dispense_position_above_z_touch_off, - ll=lld_sensitivity, - lv=pressure_lld_sensitivity, - de=swap_speed, - wt=settling_time, - mv=mix_volume, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - ms=mix_speed, - mh=surface_following_distance_during_mixing, - la=TODO_DD_2, - gj=tadm_algorithm_on_off, - gi=limit_curve_index, - gk=recording_mode, + + return await self._vantage_pip._pip_dispense( + x_position=x_position, + y_position=y_position, + tip_pattern=tip_pattern, + type_of_dispensing_mode=type_of_dispensing_mode, + minimum_height=[v / 10 for v in minimum_height], + lld_search_height=[v / 10 for v in lld_search_height], + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + v / 10 for v in pull_out_distance_to_take_transport_air_in_function_without_lld + ], + immersion_depth=[v / 10 for v in immersion_depth], + surface_following_distance=[v / 10 for v in surface_following_distance], + tube_2nd_section_height_measured_from_zm=[ + v / 10 for v in tube_2nd_section_height_measured_from_zm + ], + tube_2nd_section_ratio=[v / 10 for v in tube_2nd_section_ratio], + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + dispense_volume=[v / 100 for v in dispense_volume], + dispense_speed=[v / 10 for v in dispense_speed], + cut_off_speed=[v / 10 for v in cut_off_speed], + stop_back_volume=[v / 100 for v in stop_back_volume], + transport_air_volume=[v / 10 for v in transport_air_volume], + blow_out_air_volume=[v / 100 for v in blow_out_air_volume], + lld_mode=lld_mode, + side_touch_off_distance=side_touch_off_distance / 10, + dispense_position_above_z_touch_off=[v / 10 for v in dispense_position_above_z_touch_off], + lld_sensitivity=lld_sensitivity, + pressure_lld_sensitivity=pressure_lld_sensitivity, + swap_speed=[v / 10 for v in swap_speed], + settling_time=[v / 10 for v in settling_time], + mix_volume=[v / 100 for v in mix_volume], + mix_cycles=mix_cycles, + mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, + mix_speed=[v / 10 for v in mix_speed], + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, + TODO_DD_2=TODO_DD_2, ) async def simultaneous_aspiration_dispensation_of_liquid( @@ -2132,339 +1335,168 @@ async def simultaneous_aspiration_dispensation_of_liquid( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Simultaneous aspiration & dispensation of liquid - - Args: - type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). - type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at - surface 3 = Blow at surface 4 = Empty. - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - TODO_DM_1: (0). - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - lld_search_height: LLD search height [0.1mm]. - clot_detection_height: (0). - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - pull_out_distance_to_take_transport_air_in_function_without_lld: - Pull out distance to take transp. air in function without LLD [0.1mm] - . - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - immersion_depth: Immersion depth [0.1mm]. - surface_following_distance: Surface following distance [0.1mm]. - tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. - tube_2nd_section_ratio: Tube 2nd section ratio. - aspiration_volume: Aspiration volume [0.01ul]. - TODO_DM_3: (0). - aspiration_speed: Aspiration speed [0.1ul]/s. - dispense_volume: Dispense volume [0.01ul]. - dispense_speed: Dispense speed [0.1ul/s]. - cut_off_speed: Cut off speed [0.1ul/s]. - stop_back_volume: Stop back volume [0.1ul]. - transport_air_volume: Transport air volume [0.1ul]. - blow_out_air_volume: Blow out air volume [0.01ul]. - pre_wetting_volume: Pre wetting volume [0.1ul]. - lld_mode: LLD Mode (0 = off). - aspirate_position_above_z_touch_off: (0). - lld_sensitivity: LLD sensitivity (1 = high, 4 = low). - pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - settling_time: Settling time [0.1s]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid - surface[0.1mm]. - mix_speed: Mix speed [0.1ul/s]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - TODO_DM_5: (0). - capacitive_mad_supervision_on_off: Capacitive MAD supervision on/off (0 = OFF). - pressure_mad_supervision_on_off: Pressure MAD supervision on/off (0 = OFF). - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - limit_curve_index: Limit curve index. - recording_mode: - Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) - . - """ - + """Deprecated: delegates to VantagePIPBackend.simultaneous_aspiration_dispensation_of_liquid.""" + n = self.num_channels if type_of_aspiration is None: - type_of_aspiration = [0] * self.num_channels - elif not all(0 <= x <= 2 for x in type_of_aspiration): - raise ValueError("type_of_aspiration must be in range 0 to 2") - + type_of_aspiration = [0] * n if type_of_dispensing_mode is None: - type_of_dispensing_mode = [0] * self.num_channels - elif not all(0 <= x <= 4 for x in type_of_dispensing_mode): - raise ValueError("type_of_dispensing_mode must be in range 0 to 4") - + type_of_dispensing_mode = [0] * n if tip_pattern is None: - tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - + tip_pattern = [False] * n if TODO_DM_1 is None: - TODO_DM_1 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DM_1): - raise ValueError("TODO_DM_1 must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - + TODO_DM_1 = [0] * n if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - + y_position = [3000] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - + minimal_traverse_height_at_begin_of_command = [3600] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - + minimal_height_at_command_end = [3600] * n if lld_search_height is None: - lld_search_height = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in lld_search_height): - raise ValueError("lld_search_height must be in range 0 to 3600") - + lld_search_height = [0] * n if clot_detection_height is None: - clot_detection_height = [60] * self.num_channels - elif not all(0 <= x <= 500 for x in clot_detection_height): - raise ValueError("clot_detection_height must be in range 0 to 500") - + clot_detection_height = [60] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - + liquid_surface_at_function_without_lld = [3600] * n if pull_out_distance_to_take_transport_air_in_function_without_lld is None: - pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels - elif not all( - 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld - ): - raise ValueError( - "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 3600" - ) - + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * n if minimum_height is None: - minimum_height = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimum_height): - raise ValueError("minimum_height must be in range 0 to 3600") - + minimum_height = [3600] * n if immersion_depth is None: - immersion_depth = [0] * self.num_channels - elif not all(-3600 <= x <= 3600 for x in immersion_depth): - raise ValueError("immersion_depth must be in range -3600 to 3600") - + immersion_depth = [0] * n if surface_following_distance is None: - surface_following_distance = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance): - raise ValueError("surface_following_distance must be in range 0 to 3600") - + surface_following_distance = [0] * n if tube_2nd_section_height_measured_from_zm is None: - tube_2nd_section_height_measured_from_zm = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): - raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") - + tube_2nd_section_height_measured_from_zm = [0] * n if tube_2nd_section_ratio is None: - tube_2nd_section_ratio = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): - raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") - + tube_2nd_section_ratio = [0] * n if aspiration_volume is None: - aspiration_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in aspiration_volume): - raise ValueError("aspiration_volume must be in range 0 to 125000") - + aspiration_volume = [0] * n if TODO_DM_3 is None: - TODO_DM_3 = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in TODO_DM_3): - raise ValueError("TODO_DM_3 must be in range 0 to 125000") - + TODO_DM_3 = [0] * n if aspiration_speed is None: - aspiration_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in aspiration_speed): - raise ValueError("aspiration_speed must be in range 10 to 10000") - + aspiration_speed = [500] * n if dispense_volume is None: - dispense_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in dispense_volume): - raise ValueError("dispense_volume must be in range 0 to 125000") - + dispense_volume = [0] * n if dispense_speed is None: - dispense_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in dispense_speed): - raise ValueError("dispense_speed must be in range 10 to 10000") - + dispense_speed = [500] * n if cut_off_speed is None: - cut_off_speed = [250] * self.num_channels - elif not all(10 <= x <= 10000 for x in cut_off_speed): - raise ValueError("cut_off_speed must be in range 10 to 10000") - + cut_off_speed = [250] * n if stop_back_volume is None: - stop_back_volume = [0] * self.num_channels - elif not all(0 <= x <= 180 for x in stop_back_volume): - raise ValueError("stop_back_volume must be in range 0 to 180") - + stop_back_volume = [0] * n if transport_air_volume is None: - transport_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 500 for x in transport_air_volume): - raise ValueError("transport_air_volume must be in range 0 to 500") - + transport_air_volume = [0] * n if blow_out_air_volume is None: - blow_out_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in blow_out_air_volume): - raise ValueError("blow_out_air_volume must be in range 0 to 125000") - + blow_out_air_volume = [0] * n if pre_wetting_volume is None: - pre_wetting_volume = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in pre_wetting_volume): - raise ValueError("pre_wetting_volume must be in range 0 to 999") - + pre_wetting_volume = [0] * n if lld_mode is None: - lld_mode = [1] * self.num_channels - elif not all(0 <= x <= 4 for x in lld_mode): - raise ValueError("lld_mode must be in range 0 to 4") - + lld_mode = [1] * n if aspirate_position_above_z_touch_off is None: - aspirate_position_above_z_touch_off = [5] * self.num_channels - elif not all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off): - raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 100") - + aspirate_position_above_z_touch_off = [5] * n if lld_sensitivity is None: - lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in lld_sensitivity): - raise ValueError("lld_sensitivity must be in range 1 to 4") - + lld_sensitivity = [1] * n if pressure_lld_sensitivity is None: - pressure_lld_sensitivity = [1] * self.num_channels - elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): - raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") - + pressure_lld_sensitivity = [1] * n if swap_speed is None: - swap_speed = [100] * self.num_channels - elif not all(3 <= x <= 1600 for x in swap_speed): - raise ValueError("swap_speed must be in range 3 to 1600") - + swap_speed = [100] * n if settling_time is None: - settling_time = [5] * self.num_channels - elif not all(0 <= x <= 99 for x in settling_time): - raise ValueError("settling_time must be in range 0 to 99") - + settling_time = [5] * n if mix_volume is None: - mix_volume = [0] * self.num_channels - elif not all(0 <= x <= 12500 for x in mix_volume): - raise ValueError("mix_volume must be in range 0 to 12500") - + mix_volume = [0] * n if mix_cycles is None: - mix_cycles = [0] * self.num_channels - elif not all(0 <= x <= 99 for x in mix_cycles): - raise ValueError("mix_cycles must be in range 0 to 99") - + mix_cycles = [0] * n if mix_position_in_z_direction_from_liquid_surface is None: - mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels - elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): - raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") - + mix_position_in_z_direction_from_liquid_surface = [250] * n if mix_speed is None: - mix_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in mix_speed): - raise ValueError("mix_speed must be in range 10 to 10000") - + mix_speed = [500] * n if surface_following_distance_during_mixing is None: - surface_following_distance_during_mixing = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") - + surface_following_distance_during_mixing = [0] * n if TODO_DM_5 is None: - TODO_DM_5 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DM_5): - raise ValueError("TODO_DM_5 must be in range 0 to 1") - + TODO_DM_5 = [0] * n if capacitive_mad_supervision_on_off is None: - capacitive_mad_supervision_on_off = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): - raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") - + capacitive_mad_supervision_on_off = [0] * n if pressure_mad_supervision_on_off is None: - pressure_mad_supervision_on_off = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): - raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - + pressure_mad_supervision_on_off = [0] * n if limit_curve_index is None: - limit_curve_index = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in limit_curve_index): - raise ValueError("limit_curve_index must be in range 0 to 999") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1PM", - command="DM", - at=type_of_aspiration, - dm=type_of_dispensing_mode, - tm=tip_pattern, - dd=TODO_DM_1, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - lp=lld_search_height, - ch=clot_detection_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - zx=minimum_height, - ip=immersion_depth, - fp=surface_following_distance, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - av=aspiration_volume, - ar=TODO_DM_3, - as_=aspiration_speed, - dv=dispense_volume, - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - ba=blow_out_air_volume, - oa=pre_wetting_volume, - lm=lld_mode, - zo=aspirate_position_above_z_touch_off, - ll=lld_sensitivity, - lv=pressure_lld_sensitivity, - de=swap_speed, - wt=settling_time, - mv=mix_volume, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - ms=mix_speed, - mh=surface_following_distance_during_mixing, - la=TODO_DM_5, - lb=capacitive_mad_supervision_on_off, - lc=pressure_mad_supervision_on_off, - gj=tadm_algorithm_on_off, - gi=limit_curve_index, - gk=recording_mode, + limit_curve_index = [0] * n + + return await self._vantage_pip.simultaneous_aspiration_dispensation_of_liquid( + x_position=x_position, + y_position=y_position, # x_position and y_position are already in 0.1mm (firmware units) + type_of_aspiration=type_of_aspiration, + type_of_dispensing_mode=type_of_dispensing_mode, + tip_pattern=tip_pattern, + TODO_DM_1=TODO_DM_1, + # distances: 0.1mm -> mm (/10) + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + lld_search_height=[v / 10 for v in lld_search_height], + clot_detection_height=[v / 10 for v in clot_detection_height], + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + v / 10 for v in pull_out_distance_to_take_transport_air_in_function_without_lld + ], + minimum_height=[v / 10 for v in minimum_height], + immersion_depth=[v / 10 for v in immersion_depth], + surface_following_distance=[v / 10 for v in surface_following_distance], + tube_2nd_section_height_measured_from_zm=[ + v / 10 for v in tube_2nd_section_height_measured_from_zm + ], + tube_2nd_section_ratio=tube_2nd_section_ratio, + # volumes: 0.01uL -> uL (/100) + aspiration_volume=[v / 100 for v in aspiration_volume], + TODO_DM_3=[v / 100 for v in TODO_DM_3], + dispense_volume=[v / 100 for v in dispense_volume], + blow_out_air_volume=[v / 100 for v in blow_out_air_volume], + # speeds: 0.1uL/s -> uL/s (/10) + aspiration_speed=[v / 10 for v in aspiration_speed], + dispense_speed=[v / 10 for v in dispense_speed], + cut_off_speed=[v / 10 for v in cut_off_speed], + mix_speed=[v / 10 for v in mix_speed], + # volumes: 0.1uL -> uL (/10) + stop_back_volume=[v / 10 for v in stop_back_volume], + transport_air_volume=[v / 10 for v in transport_air_volume], + pre_wetting_volume=[v / 10 for v in pre_wetting_volume], + mix_volume=[v / 10 for v in mix_volume], + lld_mode=lld_mode, + # distance: 0.1mm -> mm (/10) + aspirate_position_above_z_touch_off=[v / 10 for v in aspirate_position_above_z_touch_off], + lld_sensitivity=lld_sensitivity, + pressure_lld_sensitivity=pressure_lld_sensitivity, + # swap_speed: 0.1mm/s -> mm/s (/10) + swap_speed=[v / 10 for v in swap_speed], + # settling_time: 0.1s -> s (/10) + settling_time=[v / 10 for v in settling_time], + mix_cycles=mix_cycles, + # distance: 0.1mm -> mm (/10) + mix_position_in_z_direction_from_liquid_surface=[ + v / 10 for v in mix_position_in_z_direction_from_liquid_surface + ], + surface_following_distance_during_mixing=[ + v / 10 for v in surface_following_distance_during_mixing + ], + TODO_DM_5=TODO_DM_5, + capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off, + pressure_mad_supervision_on_off=pressure_mad_supervision_on_off, + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, ) async def dispense_on_fly( self, y_position: List[int], tip_pattern: Optional[List[bool]] = None, - first_shoot_x_pos: int = 0, # 1 - dispense_on_fly_pos_command_end: int = 0, # 2 - x_acceleration_distance_before_first_shoot: int = 100, # 3 - space_between_shoots: int = 900, # 4 + first_shoot_x_pos: int = 0, + dispense_on_fly_pos_command_end: int = 0, + x_acceleration_distance_before_first_shoot: int = 100, + space_between_shoots: int = 900, x_speed: int = 270, - number_of_shoots: int = 1, # 5 + number_of_shoots: int = 1, minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, minimal_height_at_command_end: Optional[List[int]] = None, liquid_surface_at_function_without_lld: Optional[List[int]] = None, @@ -2477,133 +1509,55 @@ async def dispense_on_fly( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Dispense on fly - - Args: - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - first_shoot_x_pos: First shoot X-position [0.1mm] - dispense_on_fly_pos_command_end: Dispense on fly position on command end [0.1mm] - x_acceleration_distance_before_first_shoot: X- acceleration distance before first shoot - [0.1mm] Space between shoots (raster pitch) [0.01mm] - space_between_shoots: Space between shoots (raster pitch) [0.01mm] - x_speed: X speed [0.1mm/s]. - number_of_shoots: Number of shoots - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - y_position: Y Position [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - dispense_volume: Dispense volume [0.01ul]. - dispense_speed: Dispense speed [0.1ul/s]. - cut_off_speed: Cut off speed [0.1ul/s]. - stop_back_volume: Stop back volume [0.1ul]. - transport_air_volume: Transport air volume [0.1ul]. - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - limit_curve_index: Limit curve index. - recording_mode: Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements). - """ - + """Deprecated: delegates to VantagePIPBackend.dispense_on_fly.""" + n = self.num_channels if tip_pattern is None: - tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not -50000 <= first_shoot_x_pos <= 50000: - raise ValueError("first_shoot_x_pos must be in range -50000 to 50000") - - if not -50000 <= dispense_on_fly_pos_command_end <= 50000: - raise ValueError("dispense_on_fly_pos_command_end must be in range -50000 to 50000") - - if not 0 <= x_acceleration_distance_before_first_shoot <= 900: - raise ValueError("x_acceleration_distance_before_first_shoot must be in range 0 to 900") - - if not 1 <= space_between_shoots <= 2500: - raise ValueError("space_between_shoots must be in range 1 to 2500") - - if not 20 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 20 to 25000") - - if not 1 <= number_of_shoots <= 48: - raise ValueError("number_of_shoots must be in range 1 to 48") - + tip_pattern = [False] * n + if y_position is None: + y_position = [3000] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - + minimal_traverse_height_at_begin_of_command = [3600] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - + minimal_height_at_command_end = [3600] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - + liquid_surface_at_function_without_lld = [3600] * n if dispense_volume is None: - dispense_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in dispense_volume): - raise ValueError("dispense_volume must be in range 0 to 125000") - + dispense_volume = [0] * n if dispense_speed is None: - dispense_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in dispense_speed): - raise ValueError("dispense_speed must be in range 10 to 10000") - + dispense_speed = [500] * n if cut_off_speed is None: - cut_off_speed = [250] * self.num_channels - elif not all(10 <= x <= 10000 for x in cut_off_speed): - raise ValueError("cut_off_speed must be in range 10 to 10000") - + cut_off_speed = [250] * n if stop_back_volume is None: - stop_back_volume = [0] * self.num_channels - elif not all(0 <= x <= 180 for x in stop_back_volume): - raise ValueError("stop_back_volume must be in range 0 to 180") - + stop_back_volume = [0] * n if transport_air_volume is None: - transport_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 500 for x in transport_air_volume): - raise ValueError("transport_air_volume must be in range 0 to 500") - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - + transport_air_volume = [0] * n if limit_curve_index is None: - limit_curve_index = [0] * self.num_channels - elif not all(0 <= x <= 999 for x in limit_curve_index): - raise ValueError("limit_curve_index must be in range 0 to 999") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1PM", - command="DF", - tm=tip_pattern, - xa=first_shoot_x_pos, - xf=dispense_on_fly_pos_command_end, - xh=x_acceleration_distance_before_first_shoot, - xy=space_between_shoots, - xv=x_speed, - xi=number_of_shoots, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - yp=y_position, - zl=liquid_surface_at_function_without_lld, - dv=dispense_volume, - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - gj=tadm_algorithm_on_off, - gi=limit_curve_index, - gk=recording_mode, + limit_curve_index = [0] * n + + return await self._vantage_pip.dispense_on_fly( + y_position=[v / 10 for v in y_position], + tip_pattern=tip_pattern, + first_shoot_x_pos=first_shoot_x_pos / 10, + dispense_on_fly_pos_command_end=dispense_on_fly_pos_command_end / 10, + x_acceleration_distance_before_first_shoot=x_acceleration_distance_before_first_shoot / 10, + space_between_shoots=space_between_shoots / 100, + x_speed=x_speed / 10, + number_of_shoots=number_of_shoots, + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + dispense_volume=[v / 100 for v in dispense_volume], + dispense_speed=[v / 10 for v in dispense_speed], + cut_off_speed=[v / 10 for v in cut_off_speed], + stop_back_volume=[v / 10 for v in stop_back_volume], + transport_air_volume=[v / 10 for v in transport_air_volume], + tadm_algorithm_on_off=tadm_algorithm_on_off, + limit_curve_index=limit_curve_index, + recording_mode=recording_mode, ) async def nano_pulse_dispense( @@ -2627,139 +1581,66 @@ async def nano_pulse_dispense( TODO_DB_11: Optional[List[int]] = None, TODO_DB_12: Optional[List[int]] = None, ): - """Nano pulse dispense - - Args: - TODO_DB_0: (0). - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - TODO_DB_1: (0). - TODO_DB_2: (0). - TODO_DB_3: (0). - TODO_DB_4: (0). - TODO_DB_5: (0). - TODO_DB_6: (0). - TODO_DB_7: (0). - TODO_DB_8: (0). - TODO_DB_9: (0). - TODO_DB_10: (0). - TODO_DB_11: (0). - TODO_DB_12: (0). - """ - + """Deprecated: delegates to VantagePIPBackend.nano_pulse_dispense.""" + n = self.num_channels if TODO_DB_0 is None: - TODO_DB_0 = [1] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DB_0): - raise ValueError("TODO_DB_0 must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - + TODO_DB_0 = [1] * n if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - + y_position = [3000] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - + liquid_surface_at_function_without_lld = [3600] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - + minimal_traverse_height_at_begin_of_command = [3600] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - + minimal_height_at_command_end = [3600] * n if TODO_DB_1 is None: - TODO_DB_1 = [0] * self.num_channels - elif not all(0 <= x <= 20000 for x in TODO_DB_1): - raise ValueError("TODO_DB_1 must be in range 0 to 20000") - + TODO_DB_1 = [0] * n if TODO_DB_2 is None: - TODO_DB_2 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DB_2): - raise ValueError("TODO_DB_2 must be in range 0 to 1") - + TODO_DB_2 = [0] * n if TODO_DB_3 is None: - TODO_DB_3 = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in TODO_DB_3): - raise ValueError("TODO_DB_3 must be in range 0 to 10000") - + TODO_DB_3 = [0] * n if TODO_DB_4 is None: - TODO_DB_4 = [0] * self.num_channels - elif not all(0 <= x <= 100 for x in TODO_DB_4): - raise ValueError("TODO_DB_4 must be in range 0 to 100") - + TODO_DB_4 = [0] * n if TODO_DB_5 is None: - TODO_DB_5 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DB_5): - raise ValueError("TODO_DB_5 must be in range 0 to 1") - + TODO_DB_5 = [0] * n if TODO_DB_6 is None: - TODO_DB_6 = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in TODO_DB_6): - raise ValueError("TODO_DB_6 must be in range 0 to 10000") - + TODO_DB_6 = [0] * n if TODO_DB_7 is None: - TODO_DB_7 = [0] * self.num_channels - elif not all(0 <= x <= 100 for x in TODO_DB_7): - raise ValueError("TODO_DB_7 must be in range 0 to 100") - + TODO_DB_7 = [0] * n if TODO_DB_8 is None: - TODO_DB_8 = [0] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DB_8): - raise ValueError("TODO_DB_8 must be in range 0 to 1") - + TODO_DB_8 = [0] * n if TODO_DB_9 is None: - TODO_DB_9 = [0] * self.num_channels - elif not all(0 <= x <= 10000 for x in TODO_DB_9): - raise ValueError("TODO_DB_9 must be in range 0 to 10000") - + TODO_DB_9 = [0] * n if TODO_DB_10 is None: - TODO_DB_10 = [0] * self.num_channels - elif not all(0 <= x <= 100 for x in TODO_DB_10): - raise ValueError("TODO_DB_10 must be in range 0 to 100") - + TODO_DB_10 = [0] * n if TODO_DB_11 is None: - TODO_DB_11 = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in TODO_DB_11): - raise ValueError("TODO_DB_11 must be in range 0 to 3600") - + TODO_DB_11 = [0] * n if TODO_DB_12 is None: - TODO_DB_12 = [1] * self.num_channels - elif not all(0 <= x <= 1 for x in TODO_DB_12): - raise ValueError("TODO_DB_12 must be in range 0 to 1") - - return await self.send_command( - module="A1PM", - command="DB", - tm=TODO_DB_0, - xp=x_position, - yp=y_position, - zl=liquid_surface_at_function_without_lld, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - pe=TODO_DB_1, - pd=TODO_DB_2, - pf=TODO_DB_3, - pg=TODO_DB_4, - ph=TODO_DB_5, - pj=TODO_DB_6, - pk=TODO_DB_7, - pl=TODO_DB_8, - pp=TODO_DB_9, - pq=TODO_DB_10, - pi=TODO_DB_11, - pm=TODO_DB_12, + TODO_DB_12 = [1] * n + + return await self._vantage_pip.nano_pulse_dispense( + x_position=x_position, + y_position=[v / 10 for v in y_position], + TODO_DB_0=TODO_DB_0, + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + TODO_DB_1=TODO_DB_1, + TODO_DB_2=TODO_DB_2, + TODO_DB_3=TODO_DB_3, + TODO_DB_4=TODO_DB_4, + TODO_DB_5=TODO_DB_5, + TODO_DB_6=TODO_DB_6, + TODO_DB_7=TODO_DB_7, + TODO_DB_8=TODO_DB_8, + TODO_DB_9=TODO_DB_9, + TODO_DB_10=TODO_DB_10, + TODO_DB_11=[v / 10 for v in TODO_DB_11], + TODO_DB_12=TODO_DB_12, ) async def wash_tips( @@ -2777,93 +1658,44 @@ async def wash_tips( wash_cycles: int = 0, minimal_height_at_command_end: Optional[List[int]] = None, ): - """Wash tips - - Args: - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - aspiration_volume: Aspiration volume [0.01ul]. - aspiration_speed: Aspiration speed [0.1ul]/s. - dispense_speed: Dispense speed [0.1ul/s]. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - soak_time: (0). - wash_cycles: (0). - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - + """Deprecated: delegates to VantagePIPBackend.wash_tips.""" + n = self.num_channels if tip_pattern is None: - tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - + tip_pattern = [False] * n if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - + y_position = [3000] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - + minimal_traverse_height_at_begin_of_command = [3600] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") - + liquid_surface_at_function_without_lld = [3600] * n if aspiration_volume is None: - aspiration_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in aspiration_volume): - raise ValueError("aspiration_volume must be in range 0 to 125000") - + aspiration_volume = [0] * n if aspiration_speed is None: - aspiration_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in aspiration_speed): - raise ValueError("aspiration_speed must be in range 10 to 10000") - + aspiration_speed = [500] * n if dispense_speed is None: - dispense_speed = [500] * self.num_channels - elif not all(10 <= x <= 10000 for x in dispense_speed): - raise ValueError("dispense_speed must be in range 10 to 10000") - + dispense_speed = [500] * n if swap_speed is None: - swap_speed = [100] * self.num_channels - elif not all(3 <= x <= 1600 for x in swap_speed): - raise ValueError("swap_speed must be in range 3 to 1600") - - if not 0 <= soak_time <= 3600: - raise ValueError("soak_time must be in range 0 to 3600") - - if not 0 <= wash_cycles <= 99: - raise ValueError("wash_cycles must be in range 0 to 99") - + swap_speed = [100] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DW", - tm=tip_pattern, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - zl=liquid_surface_at_function_without_lld, - av=aspiration_volume, - as_=aspiration_speed, - ds=dispense_speed, - de=swap_speed, - sa=soak_time, - dc=wash_cycles, - te=minimal_height_at_command_end, + minimal_height_at_command_end = [3600] * n + + return await self._vantage_pip.wash_tips( + x_position=x_position, + y_position=[v / 10 for v in y_position], + tip_pattern=tip_pattern, + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + liquid_surface_at_function_without_lld=[ + v / 10 for v in liquid_surface_at_function_without_lld + ], + aspiration_volume=[v / 100 for v in aspiration_volume], + aspiration_speed=[v / 10 for v in aspiration_speed], + dispense_speed=[v / 10 for v in dispense_speed], + swap_speed=[v / 10 for v in swap_speed], + soak_time=soak_time, + wash_cycles=wash_cycles, + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], ) async def pip_tip_pick_up( @@ -2879,84 +1711,38 @@ async def pip_tip_pick_up( blow_out_air_volume: Optional[List[int]] = None, tip_handling_method: Optional[List[int]] = None, ): - """Tip Pick up - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - tip_type: Tip type (see command TT). - begin_z_deposit_position: (0). - end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - blow_out_air_volume: Blow out air volume [0.01ul]. - tip_handling_method: Tip handling method. (Unconfirmed, but likely: 0 = auto selection (see - command TT parameter tu), 1 = pick up out of rack, 2 = pick up out of wash liquid (slowly)) - """ - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") + """Deprecated: use ``VantagePIPBackend._pip_tip_pick_up``.""" if tip_pattern is None: tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - if tip_type is None: tip_type = [4] * self.num_channels - elif not all(0 <= x <= 199 for x in tip_type): - raise ValueError("tip_type must be in range 0 to 199") - if begin_z_deposit_position is None: begin_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): - raise ValueError("begin_z_deposit_position must be in range 0 to 3600") - if end_z_deposit_position is None: end_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in end_z_deposit_position): - raise ValueError("end_z_deposit_position must be in range 0 to 3600") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - if blow_out_air_volume is None: blow_out_air_volume = [0] * self.num_channels - elif not all(0 <= x <= 125000 for x in blow_out_air_volume): - raise ValueError("blow_out_air_volume must be in range 0 to 125000") - if tip_handling_method is None: tip_handling_method = [0] * self.num_channels - elif not all(0 <= x <= 9 for x in tip_handling_method): - raise ValueError("tip_handling_method must be in range 0 to 9") - - return await self.send_command( - module="A1PM", - command="TP", - xp=x_position, - yp=y_position, - tm=tip_pattern, - tt=tip_type, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - ba=blow_out_air_volume, - td=tip_handling_method, + + return await self._vantage_pip._pip_tip_pick_up( + x_position=x_position, + y_position=y_position, + tip_pattern=tip_pattern, + tip_type=tip_type, + begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position], + end_z_deposit_position=[v / 10 for v in end_z_deposit_position], + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + tip_handling_method=tip_handling_method, + blow_out_air_volume=[v / 100 for v in blow_out_air_volume], ) async def pip_tip_discard( @@ -2971,74 +1757,33 @@ async def pip_tip_discard( TODO_TR_2: int = 0, tip_handling_method: Optional[List[int]] = None, ): - """Tip Discard - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - begin_z_deposit_position: (0). - end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - TODO_TR_2: (0). - tip_handling_method: Tip handling method. - """ - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - - if y_position is None: - y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") + """Deprecated: use ``VantagePIPBackend._pip_tip_discard``.""" if begin_z_deposit_position is None: begin_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): - raise ValueError("begin_z_deposit_position must be in range 0 to 3600") - if end_z_deposit_position is None: end_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in end_z_deposit_position): - raise ValueError("end_z_deposit_position must be in range 0 to 3600") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - if tip_pattern is None: tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not -1000 <= TODO_TR_2 <= 1000: - raise ValueError("TODO_TR_2 must be in range -1000 to 1000") - if tip_handling_method is None: tip_handling_method = [0] * self.num_channels - elif not all(0 <= x <= 9 for x in tip_handling_method): - raise ValueError("tip_handling_method must be in range 0 to 9") - - return await self.send_command( - module="A1PM", - command="TR", - xp=x_position, - yp=y_position, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - tm=tip_pattern, - ts=TODO_TR_2, - td=tip_handling_method, + + return await self._vantage_pip._pip_tip_discard( + x_position=x_position, + y_position=y_position, + tip_pattern=tip_pattern, + begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position], + end_z_deposit_position=[v / 10 for v in end_z_deposit_position], + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], + tip_handling_method=tip_handling_method, + TODO_TR_2=TODO_TR_2, ) async def search_for_teach_in_signal_in_x_direction( @@ -3047,71 +1792,33 @@ async def search_for_teach_in_signal_in_x_direction( x_search_distance: int = 0, x_speed: int = 270, ): - """Search for Teach in signal in X direction - - Args: - channel_index: Channel index. - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. - """ - - if not 1 <= channel_index <= 16: - raise ValueError("channel_index must be in range 1 to 16") - - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - - if not 20 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 20 to 25000") - - return await self.send_command( - module="A1PM", - command="DL", - pn=channel_index, - xs=x_search_distance, - xv=x_speed, + """Deprecated: delegates to VantagePIPBackend.search_for_teach_in_signal_in_x_direction.""" + return await self._vantage_pip.search_for_teach_in_signal_in_x_direction( + channel_index=channel_index, + x_search_distance=x_search_distance / 10, + x_speed=x_speed / 10, ) async def position_all_channels_in_y_direction( self, y_position: List[int], ): - """Position all channels in Y direction - - Args: - y_position: Y Position [0.1mm]. - """ - + """Deprecated: delegates to VantagePIPBackend.position_all_channels_in_y_direction.""" if y_position is None: y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - - return await self.send_command( - module="A1PM", - command="DY", - yp=y_position, + return await self._vantage_pip.position_all_channels_in_y_direction( + y_position=[v / 10 for v in y_position], ) async def position_all_channels_in_z_direction( self, z_position: Optional[List[int]] = None, ): - """Position all channels in Z direction - - Args: - z_position: Z Position [0.1mm]. - """ - + """Deprecated: delegates to VantagePIPBackend.position_all_channels_in_z_direction.""" if z_position is None: z_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in z_position): - raise ValueError("z_position must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DZ", - zp=z_position, + return await self._vantage_pip.position_all_channels_in_z_direction( + z_position=[v / 10 for v in z_position], ) async def position_single_channel_in_y_direction( @@ -3119,24 +1826,10 @@ async def position_single_channel_in_y_direction( channel_index: int = 1, y_position: int = 3000, ): - """Position single channel in Y direction - - Args: - channel_index: Channel index. - y_position: Y Position [0.1mm]. - """ - - if not 1 <= channel_index <= 16: - raise ValueError("channel_index must be in range 1 to 16") - - if not 0 <= y_position <= 6500: - raise ValueError("y_position must be in range 0 to 6500") - - return await self.send_command( - module="A1PM", - command="DV", - pn=channel_index, - yj=y_position, + """Deprecated: delegates to VantagePIPBackend.position_single_channel_in_y_direction.""" + return await self._vantage_pip.position_single_channel_in_y_direction( + channel_index=channel_index, + y_position=y_position / 10, ) async def position_single_channel_in_z_direction( @@ -3144,24 +1837,10 @@ async def position_single_channel_in_z_direction( channel_index: int = 1, z_position: int = 0, ): - """Position single channel in Z direction - - Args: - channel_index: Channel index. - z_position: Z Position [0.1mm]. - """ - - if not 1 <= channel_index <= 16: - raise ValueError("channel_index must be in range 1 to 16") - - if not 0 <= z_position <= 3600: - raise ValueError("z_position must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DU", - pn=channel_index, - zj=z_position, + """Deprecated: delegates to VantagePIPBackend.position_single_channel_in_z_direction.""" + return await self._vantage_pip.position_single_channel_in_z_direction( + channel_index=channel_index, + z_position=z_position / 10, ) async def move_to_defined_position( @@ -3172,48 +1851,24 @@ async def move_to_defined_position( minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, z_position: Optional[List[int]] = None, ): - """Move to defined position - - Args: - tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - z_position: Z Position [0.1mm]. - """ - + """Deprecated: delegates to VantagePIPBackend.move_to_defined_position.""" if tip_pattern is None: tip_pattern = [False] * self.num_channels - elif not all(0 <= x <= 1 for x in tip_pattern): - raise ValueError("tip_pattern must be in range 0 to 1") - - if not all(0 <= x <= 50000 for x in x_position): - raise ValueError("x_position must be in range 0 to 50000") - if y_position is None: y_position = [3000] * self.num_channels - elif not all(0 <= x <= 6500 for x in y_position): - raise ValueError("y_position must be in range 0 to 6500") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if z_position is None: z_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in z_position): - raise ValueError("z_position must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DN", - tm=tip_pattern, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - zp=z_position, + + return await self._vantage_pip.move_to_defined_position( + x_position=x_position, + y_position=[v / 10 for v in y_position], + tip_pattern=tip_pattern, + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + z_position=[v / 10 for v in z_position], ) async def teach_rack_using_channel_n( @@ -3224,62 +1879,25 @@ async def teach_rack_using_channel_n( gap_center_z_direction: int = 0, minimal_height_at_command_end: Optional[List[int]] = None, ): - """Teach rack using channel n - - Attention! Channels not involved must first be taken out of measurement range. - - Args: - channel_index: Channel index. - gap_center_x_direction: Gap center X direction [0.1mm]. - gap_center_y_direction: Gap center Y direction [0.1mm]. - gap_center_z_direction: Gap center Z direction [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not 1 <= channel_index <= 16: - raise ValueError("channel_index must be in range 1 to 16") - - if not -50000 <= gap_center_x_direction <= 50000: - raise ValueError("gap_center_x_direction must be in range -50000 to 50000") - - if not 0 <= gap_center_y_direction <= 6500: - raise ValueError("gap_center_y_direction must be in range 0 to 6500") - - if not 0 <= gap_center_z_direction <= 3600: - raise ValueError("gap_center_z_direction must be in range 0 to 3600") - + """Deprecated: delegates to VantagePIPBackend.teach_rack_using_channel_n.""" if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DT", - pn=channel_index, - xa=gap_center_x_direction, - yj=gap_center_y_direction, - zj=gap_center_z_direction, - te=minimal_height_at_command_end, + + return await self._vantage_pip.teach_rack_using_channel_n( + channel_index=channel_index, + gap_center_x_direction=gap_center_x_direction / 10, + gap_center_y_direction=gap_center_y_direction / 10, + gap_center_z_direction=gap_center_z_direction / 10, + minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], ) async def expose_channel_n( self, channel_index: int = 1, ): - """Expose channel n - - Args: - channel_index: Channel index. - """ - - if not 1 <= channel_index <= 16: - raise ValueError("channel_index must be in range 1 to 16") - - return await self.send_command( - module="A1PM", - command="DQ", - pn=channel_index, + """Deprecated: delegates to VantagePIPBackend.expose_channel_n.""" + return await self._vantage_pip.expose_channel_n( + channel_index=channel_index, ) async def calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom( @@ -3292,58 +1910,26 @@ async def calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_ minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, first_pip_channel_node_no: int = 1, ): - """Calculates check sums and compares them with the value saved in Flash EPROM - - Args: - TODO_DC_0: (0). - TODO_DC_1: (0). - tip_type: Tip type (see command TT). - TODO_DC_2: (0). - z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). - """ - - if not -50000 <= TODO_DC_0 <= 50000: - raise ValueError("TODO_DC_0 must be in range -50000 to 50000") - - if not 0 <= TODO_DC_1 <= 6500: - raise ValueError("TODO_DC_1 must be in range 0 to 6500") - + """Deprecated: delegates to VantagePIPBackend.calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom.""" if tip_type is None: tip_type = [4] * self.num_channels - elif not all(0 <= x <= 199 for x in tip_type): - raise ValueError("tip_type must be in range 0 to 199") - if TODO_DC_2 is None: TODO_DC_2 = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in TODO_DC_2): - raise ValueError("TODO_DC_2 must be in range 0 to 3600") - if z_deposit_position is None: z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in z_deposit_position): - raise ValueError("z_deposit_position must be in range 0 to 3600") - if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - - if not 1 <= first_pip_channel_node_no <= 16: - raise ValueError("first_pip_channel_node_no must be in range 1 to 16") - - return await self.send_command( - module="A1PM", - command="DC", - xa=TODO_DC_0, - yj=TODO_DC_1, - tt=tip_type, - tp=TODO_DC_2, - tz=z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - pa=first_pip_channel_node_no, + + return await self._vantage_pip.calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom( + TODO_DC_0=TODO_DC_0 / 10, + TODO_DC_1=TODO_DC_1 / 10, + tip_type=tip_type, + TODO_DC_2=[v / 10 for v in TODO_DC_2], + z_deposit_position=[v / 10 for v in z_deposit_position], + minimal_traverse_height_at_begin_of_command=[ + v / 10 for v in minimal_traverse_height_at_begin_of_command + ], + first_pip_channel_node_no=first_pip_channel_node_no, ) async def discard_core_gripper_tool( @@ -3357,65 +1943,20 @@ async def discard_core_gripper_tool( first_pip_channel_node_no: int = 1, minimal_height_at_command_end: Optional[List[int]] = None, ): - """Discard CoRe gripper tool - - Args: - gripper_tool_x_position: (0). - first_gripper_tool_y_pos: First (lower channel) CoRe gripper tool Y pos. [0.1mm] - tip_type: Tip type (see command TT). - begin_z_deposit_position: (0). - end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -50000 <= gripper_tool_x_position <= 50000: - raise ValueError("gripper_tool_x_position must be in range -50000 to 50000") - - if not 0 <= first_gripper_tool_y_pos <= 6500: - raise ValueError("first_gripper_tool_y_pos must be in range 0 to 6500") - - if tip_type is None: - tip_type = [4] * self.num_channels - elif not all(0 <= x <= 199 for x in tip_type): - raise ValueError("tip_type must be in range 0 to 199") - - if begin_z_deposit_position is None: - begin_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): - raise ValueError("begin_z_deposit_position must be in range 0 to 3600") - - if end_z_deposit_position is None: - end_z_deposit_position = [0] * self.num_channels - elif not all(0 <= x <= 3600 for x in end_z_deposit_position): - raise ValueError("end_z_deposit_position must be in range 0 to 3600") + """Deprecated: delegates to VantageCoreGripper.discard_tool. Use that instead.""" if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - - if not 1 <= first_pip_channel_node_no <= 16: - raise ValueError("first_pip_channel_node_no must be in range 1 to 16") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DJ", - xa=gripper_tool_x_position, - yj=first_gripper_tool_y_pos, - tt=tip_type, - tp=begin_z_deposit_position, - tz=end_z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - pa=first_pip_channel_node_no, - te=minimal_height_at_command_end, + + return await self._vantage_core_gripper.discard_tool( + x_position=gripper_tool_x_position / 10, + first_gripper_tool_y_pos=first_gripper_tool_y_pos / 10, + first_pip_channel_node_no=first_pip_channel_node_no, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command[0] + / 10, + minimal_height_at_command_end=minimal_height_at_command_end[0] / 10, ) async def grip_plate( @@ -3431,69 +1972,25 @@ async def grip_plate( minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, minimal_height_at_command_end: Optional[List[int]] = None, ): - """Grip plate - - Args: - plate_center_x_direction: Plate center X direction [0.1mm]. - plate_center_y_direction: Plate center Y direction [0.1mm]. - plate_center_z_direction: Plate center Z direction [0.1mm]. - z_speed: Z speed [0.1mm/sec]. - open_gripper_position: Open gripper position [0.1mm]. - plate_width: Plate width [0.1mm]. - acceleration_index: Acceleration index. - grip_strength: Grip strength (0 = low 99 = high). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -50000 <= plate_center_x_direction <= 50000: - raise ValueError("plate_center_x_direction must be in range -50000 to 50000") - - if not 0 <= plate_center_y_direction <= 6500: - raise ValueError("plate_center_y_direction must be in range 0 to 6500") - - if not 0 <= plate_center_z_direction <= 3600: - raise ValueError("plate_center_z_direction must be in range 0 to 3600") - - if not 3 <= z_speed <= 1600: - raise ValueError("z_speed must be in range 3 to 1600") - - if not 0 <= open_gripper_position <= 9999: - raise ValueError("open_gripper_position must be in range 0 to 9999") - - if not 0 <= plate_width <= 9999: - raise ValueError("plate_width must be in range 0 to 9999") - - if not 0 <= acceleration_index <= 4: - raise ValueError("acceleration_index must be in range 0 to 4") - - if not 0 <= grip_strength <= 99: - raise ValueError("grip_strength must be in range 0 to 99") + """Deprecated: delegates to VantageCoreGripper._grip_plate. Use that instead.""" if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DG", - xa=plate_center_x_direction, - yj=plate_center_y_direction, - zj=plate_center_z_direction, - zy=z_speed, - yo=open_gripper_position, - yg=plate_width, - ai=acceleration_index, - yw=grip_strength, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + + return await self._vantage_core_gripper._grip_plate( + x_position=plate_center_x_direction / 10, + y_position=plate_center_y_direction / 10, + z_position=plate_center_z_direction / 10, + z_speed=z_speed / 10, + open_gripper_position=open_gripper_position / 10, + plate_width=plate_width / 10, + acceleration_index=acceleration_index, + grip_strength=grip_strength, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command[0] + / 10, + minimal_height_at_command_end=minimal_height_at_command_end[0] / 10, ) async def put_plate( @@ -3507,59 +2004,23 @@ async def put_plate( minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, minimal_height_at_command_end: Optional[List[int]] = None, ): - """Put plate - - Args: - plate_center_x_direction: Plate center X direction [0.1mm]. - plate_center_y_direction: Plate center Y direction [0.1mm]. - plate_center_z_direction: Plate center Z direction [0.1mm]. - press_on_distance: Press on distance [0.1mm]. - z_speed: Z speed [0.1mm/sec]. - open_gripper_position: Open gripper position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -50000 <= plate_center_x_direction <= 50000: - raise ValueError("plate_center_x_direction must be in range -50000 to 50000") - - if not 0 <= plate_center_y_direction <= 6500: - raise ValueError("plate_center_y_direction must be in range 0 to 6500") - - if not 0 <= plate_center_z_direction <= 3600: - raise ValueError("plate_center_z_direction must be in range 0 to 3600") - - if not 0 <= press_on_distance <= 999: - raise ValueError("press_on_distance must be in range 0 to 999") - - if not 3 <= z_speed <= 1600: - raise ValueError("z_speed must be in range 3 to 1600") - - if not 0 <= open_gripper_position <= 9999: - raise ValueError("open_gripper_position must be in range 0 to 9999") + """Deprecated: delegates to VantageCoreGripper._put_plate. Use that instead.""" if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - if minimal_height_at_command_end is None: minimal_height_at_command_end = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): - raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DR", - xa=plate_center_x_direction, - yj=plate_center_y_direction, - zj=plate_center_z_direction, - zi=press_on_distance, - zy=z_speed, - yo=open_gripper_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + + return await self._vantage_core_gripper._put_plate( + x_position=plate_center_x_direction / 10, + y_position=plate_center_y_direction / 10, + z_position=plate_center_z_direction / 10, + press_on_distance=press_on_distance / 10, + z_speed=z_speed / 10, + open_gripper_position=open_gripper_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command[0] + / 10, + minimal_height_at_command_end=minimal_height_at_command_end[0] / 10, ) async def move_to_position( @@ -3570,149 +2031,71 @@ async def move_to_position( z_speed: int = 1287, minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, ): - """Move to position - - Args: - plate_center_x_direction: Plate center X direction [0.1mm]. - plate_center_y_direction: Plate center Y direction [0.1mm]. - plate_center_z_direction: Plate center Z direction [0.1mm]. - z_speed: Z speed [0.1mm/sec]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - """ - - if not -50000 <= plate_center_x_direction <= 50000: - raise ValueError("plate_center_x_direction must be in range -50000 to 50000") - - if not 0 <= plate_center_y_direction <= 6500: - raise ValueError("plate_center_y_direction must be in range 0 to 6500") - - if not 0 <= plate_center_z_direction <= 3600: - raise ValueError("plate_center_z_direction must be in range 0 to 3600") - - if not 3 <= z_speed <= 1600: - raise ValueError("z_speed must be in range 3 to 1600") + """Deprecated: delegates to VantageCoreGripper._move_to_position. Use that instead.""" if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels - elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") - - return await self.send_command( - module="A1PM", - command="DH", - xa=plate_center_x_direction, - yj=plate_center_y_direction, - zj=plate_center_z_direction, - zy=z_speed, - th=minimal_traverse_height_at_begin_of_command, + + return await self._vantage_core_gripper._move_to_position( + x_position=plate_center_x_direction / 10, + y_position=plate_center_y_direction / 10, + z_position=plate_center_z_direction / 10, + z_speed=z_speed / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command[0] + / 10, ) async def release_object( self, first_pip_channel_node_no: int = 1, ): - """Release object + """Deprecated: delegates to VantageCoreGripper.open_gripper. Use that instead.""" - Args: - first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). - """ - - if not 1 <= first_pip_channel_node_no <= 16: - raise ValueError("first_pip_channel_node_no must be in range 1 to 16") - - return await self.send_command( - module="A1PM", - command="DO", - pa=first_pip_channel_node_no, - ) + return await self._vantage_core_gripper.open_gripper(0) async def set_any_parameter_within_this_module(self): - """Set any parameter within this module""" - - return await self.send_command( - module="A1PM", - command="AA", - ) + """Deprecated: delegates to VantagePIPBackend.set_any_parameter_within_this_module.""" + return await self._vantage_pip.set_any_parameter_within_this_module() async def request_y_positions_of_all_channels(self): - """Request Y Positions of all channels""" - - return await self.send_command( - module="A1PM", - command="RY", - ) + """Deprecated: delegates to VantagePIPBackend.request_y_positions_of_all_channels.""" + return await self._vantage_pip.request_y_positions_of_all_channels() async def request_y_position_of_channel_n(self, channel_index: int = 1): - """Request Y Position of channel n""" - - return await self.send_command( - module="A1PM", - command="RB", - pn=channel_index, + """Deprecated: delegates to VantagePIPBackend.request_y_position_of_channel_n.""" + return await self._vantage_pip.request_y_position_of_channel_n( + channel_index=channel_index, ) async def request_z_positions_of_all_channels(self): - """Request Z Positions of all channels""" - - return await self.send_command( - module="A1PM", - command="RZ", - ) + """Deprecated: delegates to VantagePIPBackend.request_z_positions_of_all_channels.""" + return await self._vantage_pip.request_z_positions_of_all_channels() async def request_z_position_of_channel_n(self, channel_index: int = 1): - """Request Z Position of channel n""" - - return await self.send_command( - module="A1PM", - command="RD", - pn=channel_index, + """Deprecated: delegates to VantagePIPBackend.request_z_position_of_channel_n.""" + return await self._vantage_pip.request_z_position_of_channel_n( + channel_index=channel_index, ) async def query_tip_presence(self) -> List[bool]: - """Query Tip presence""" - - resp = await self.send_command(module="A1PM", command="QA", fmt={"rt": "[int]"}) - presences_int = cast(List[int], resp["rt"]) - return [bool(p) for p in presences_int] + """Deprecated: use ``VantageDriver.query_tip_presence``.""" + return await self.driver.query_tip_presence() async def request_tip_presence(self) -> List[Optional[bool]]: - """Request tip presence on each channel. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - result: List[Optional[bool]] = list(await self.query_tip_presence()) - return result + """Deprecated: use ``VantageDriver.query_tip_presence``.""" + return list(await self.query_tip_presence()) async def request_height_of_last_lld(self): - """Request height of last LLD""" - - return await self.send_command( - module="A1PM", - command="RL", - ) + """Deprecated: delegates to VantagePIPBackend.request_height_of_last_lld.""" + return await self._vantage_pip.request_height_of_last_lld() async def request_channel_dispense_on_fly_status(self): - """Request channel dispense on fly status""" - - return await self.send_command( - module="A1PM", - command="QF", - ) + """Deprecated: delegates to VantagePIPBackend.request_channel_dispense_on_fly_status.""" + return await self._vantage_pip.request_channel_dispense_on_fly_status() async def core96_request_initialization_status(self) -> bool: - """Request CoRe96 initialization status - - This method is inferred from I1AM and A1AM commands ("QW"). - - Returns: - bool: True if initialized, False otherwise. - """ - - resp = await self.send_command(module="A1HM", command="QW", fmt={"qw": "int"}) - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``VantageDriver.core96_request_initialization_status``.""" + return await self.driver.core96_request_initialization_status() async def core96_initialize( self, @@ -3724,51 +2107,18 @@ async def core96_initialize( end_z_deposit_position: int = 0, tip_type: int = 4, ): - """Initialize 96 head. + """Deprecated: use ``VantageDriver.core96_initialize``. - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_position: Z Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). (not documented, - but present in the log files.) - tip_type: Tip type (see command TT). + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. """ - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= z_position <= 3900: - raise ValueError("z_position must be in range 0 to 3900") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - if not 0 <= end_z_deposit_position <= 3600: - raise ValueError("end_z_deposit_position must be in range 0 to 3600") - - if not 0 <= tip_type <= 199: - raise ValueError("tip_type must be in range 0 to 199") - - return await self.send_command( - module="A1HM", - command="DI", - xp=x_position, - yp=y_position, - zp=z_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - tz=end_z_deposit_position, - tt=tip_type, + return await self.driver.core96_initialize( + x_position / 10, + y_position / 10, + z_position / 10, + minimal_traverse_height_at_begin_of_command / 10, + minimal_height_at_command_end / 10, + end_z_deposit_position / 10, + tip_type, ) async def core96_aspiration_of_liquid( @@ -3805,181 +2155,40 @@ async def core96_aspiration_of_liquid( tadm_algorithm_on_off: int = 0, recording_mode: int = 0, ): - """Aspiration of liquid using the 96 head. - - Args: - type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - lld_search_height: LLD search height [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - pull_out_distance_to_take_transport_air_in_function_without_lld: - Pull out distance to take transp. air in function without LLD [0.1mm]. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. - tube_2nd_section_ratio: Tube 2nd section ratio. - immersion_depth: Immersion depth [0.1mm]. - surface_following_distance: Surface following distance [0.1mm]. - aspiration_volume: Aspiration volume [0.01ul]. - aspiration_speed: Aspiration speed [0.1ul]/s. - transport_air_volume: Transport air volume [0.1ul]. - blow_out_air_volume: Blow out air volume [0.01ul]. - pre_wetting_volume: Pre wetting volume [0.1ul]. - lld_mode: LLD Mode (0 = off). - lld_sensitivity: LLD sensitivity (1 = high, 4 = low). - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - settling_time: Settling time [0.1s]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid - surface[0.1mm]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - mix_speed: Mix speed [0.1ul/s]. - limit_curve_index: Limit curve index. - tadm_channel_pattern: TADM Channel pattern. - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - recording_mode: - Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) - . - """ - - if not 0 <= type_of_aspiration <= 2: - raise ValueError("type_of_aspiration must be in range 0 to 2") - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - if not 0 <= lld_search_height <= 3900: - raise ValueError("lld_search_height must be in range 0 to 3900") - - if not 0 <= liquid_surface_at_function_without_lld <= 3900: - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") - - if not 0 <= pull_out_distance_to_take_transport_air_in_function_without_lld <= 3900: - raise ValueError( - "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 3900" - ) - - if not 0 <= minimum_height <= 3900: - raise ValueError("minimum_height must be in range 0 to 3900") - - if not 0 <= tube_2nd_section_height_measured_from_zm <= 3900: - raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3900") - - if not 0 <= tube_2nd_section_ratio <= 10000: - raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") - - if not -990 <= immersion_depth <= 990: - raise ValueError("immersion_depth must be in range -990 to 990") - - if not 0 <= surface_following_distance <= 990: - raise ValueError("surface_following_distance must be in range 0 to 990") - - if not 0 <= aspiration_volume <= 115000: - raise ValueError("aspiration_volume must be in range 0 to 115000") - - if not 3 <= aspiration_speed <= 5000: - raise ValueError("aspiration_speed must be in range 3 to 5000") - - if not 0 <= transport_air_volume <= 1000: - raise ValueError("transport_air_volume must be in range 0 to 1000") - - if not 0 <= blow_out_air_volume <= 115000: - raise ValueError("blow_out_air_volume must be in range 0 to 115000") - - if not 0 <= pre_wetting_volume <= 11500: - raise ValueError("pre_wetting_volume must be in range 0 to 11500") - - if not 0 <= lld_mode <= 1: - raise ValueError("lld_mode must be in range 0 to 1") - - if not 1 <= lld_sensitivity <= 4: - raise ValueError("lld_sensitivity must be in range 1 to 4") - - if not 3 <= swap_speed <= 1000: - raise ValueError("swap_speed must be in range 3 to 1000") - - if not 0 <= settling_time <= 99: - raise ValueError("settling_time must be in range 0 to 99") - - if not 0 <= mix_volume <= 11500: - raise ValueError("mix_volume must be in range 0 to 11500") - - if not 0 <= mix_cycles <= 99: - raise ValueError("mix_cycles must be in range 0 to 99") - - if not 0 <= mix_position_in_z_direction_from_liquid_surface <= 990: - raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 990") - - if not 0 <= surface_following_distance_during_mixing <= 990: - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") - - if not 3 <= mix_speed <= 5000: - raise ValueError("mix_speed must be in range 3 to 5000") - - if not 0 <= limit_curve_index <= 999: - raise ValueError("limit_curve_index must be in range 0 to 999") - - if tadm_channel_pattern is None: - tadm_channel_pattern = [True] * 96 - elif not len(tadm_channel_pattern) < 24: - raise ValueError( - f"tadm_channel_pattern must be of length 24, but is '{len(tadm_channel_pattern)}'" - ) - tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1HM", - command="DA", - at=type_of_aspiration, - xp=x_position, - yp=y_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - zx=minimum_height, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - ip=immersion_depth, - fp=surface_following_distance, - av=aspiration_volume, - as_=aspiration_speed, - ta=transport_air_volume, - ba=blow_out_air_volume, - oa=pre_wetting_volume, - lm=lld_mode, - ll=lld_sensitivity, - de=swap_speed, - wt=settling_time, - mv=mix_volume, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - mh=surface_following_distance_during_mixing, - ms=mix_speed, - gi=limit_curve_index, - cw=hex(tadm_channel_pattern_num)[2:].upper(), - gj=tadm_algorithm_on_off, - gk=recording_mode, + """Deprecated: use ``VantageHead96Backend._core96_aspiration_of_liquid``.""" + return await self._vantage_head96._core96_aspiration_of_liquid( + type_of_aspiration=type_of_aspiration, + x_position=x_position / 10, + y_position=y_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, + lld_search_height=lld_search_height / 10, + liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld / 10, + pull_out_distance_to_take_transport_air_in_function_without_lld=pull_out_distance_to_take_transport_air_in_function_without_lld + / 10, + minimum_height=minimum_height / 10, + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm / 10, + tube_2nd_section_ratio=tube_2nd_section_ratio / 10, + immersion_depth=immersion_depth / 10, + surface_following_distance=surface_following_distance / 10, + aspiration_volume=aspiration_volume / 100, + aspiration_speed=aspiration_speed / 10, + transport_air_volume=transport_air_volume / 10, + blow_out_air_volume=blow_out_air_volume / 100, + pre_wetting_volume=pre_wetting_volume / 100, + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + swap_speed=swap_speed / 10, + settling_time=settling_time / 10, + mix_volume=mix_volume / 100, + mix_cycles=mix_cycles, + mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + mix_speed=mix_speed / 10, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, ) async def core96_dispensing_of_liquid( @@ -4018,193 +2227,42 @@ async def core96_dispensing_of_liquid( tadm_algorithm_on_off: int = 0, recording_mode: int = 0, ): - """Dispensing of liquid using the 96 head. - - Args: - type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at - surface 3 = Blow at surface 4 = Empty. - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. - tube_2nd_section_ratio: Tube 2nd section ratio. - lld_search_height: LLD search height [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - pull_out_distance_to_take_transport_air_in_function_without_lld: - Pull out distance to take transp. air in function without LLD [0.1mm] - . - immersion_depth: Immersion depth [0.1mm]. - surface_following_distance: Surface following distance [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - dispense_volume: Dispense volume [0.01ul]. - dispense_speed: Dispense speed [0.1ul/s]. - cut_off_speed: Cut off speed [0.1ul/s]. - stop_back_volume: Stop back volume [0.1ul]. - transport_air_volume: Transport air volume [0.1ul]. - blow_out_air_volume: Blow out air volume [0.01ul]. - lld_mode: LLD Mode (0 = off). - lld_sensitivity: LLD sensitivity (1 = high, 4 = low). - side_touch_off_distance: Side touch off distance [0.1mm]. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. - settling_time: Settling time [0.1s]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid - surface[0.1mm]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - mix_speed: Mix speed [0.1ul/s]. - limit_curve_index: Limit curve index. - tadm_channel_pattern: TADM Channel pattern. - tadm_algorithm_on_off: TADM algorithm on/off (0 = off). - recording_mode: - Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) - . - """ - - if not 0 <= type_of_dispensing_mode <= 4: - raise ValueError("type_of_dispensing_mode must be in range 0 to 4") - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= minimum_height <= 3900: - raise ValueError("minimum_height must be in range 0 to 3900") - - if not 0 <= tube_2nd_section_height_measured_from_zm <= 3900: - raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3900") - - if not 0 <= tube_2nd_section_ratio <= 10000: - raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") - - if not 0 <= lld_search_height <= 3900: - raise ValueError("lld_search_height must be in range 0 to 3900") - - if not 0 <= liquid_surface_at_function_without_lld <= 3900: - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") - - if not 0 <= pull_out_distance_to_take_transport_air_in_function_without_lld <= 3900: - raise ValueError( - "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 3900" - ) - - if not -990 <= immersion_depth <= 990: - raise ValueError("immersion_depth must be in range -990 to 990") - - if not 0 <= surface_following_distance <= 990: - raise ValueError("surface_following_distance must be in range 0 to 990") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - if not 0 <= dispense_volume <= 115000: - raise ValueError("dispense_volume must be in range 0 to 115000") - - if not 3 <= dispense_speed <= 5000: - raise ValueError("dispense_speed must be in range 3 to 5000") - - if not 3 <= cut_off_speed <= 5000: - raise ValueError("cut_off_speed must be in range 3 to 5000") - - if not 0 <= stop_back_volume <= 2000: - raise ValueError("stop_back_volume must be in range 0 to 2000") - - if not 0 <= transport_air_volume <= 1000: - raise ValueError("transport_air_volume must be in range 0 to 1000") - - if not 0 <= blow_out_air_volume <= 115000: - raise ValueError("blow_out_air_volume must be in range 0 to 115000") - - if not 0 <= lld_mode <= 1: - raise ValueError("lld_mode must be in range 0 to 1") - - if not 1 <= lld_sensitivity <= 4: - raise ValueError("lld_sensitivity must be in range 1 to 4") - - if not 0 <= side_touch_off_distance <= 30: - raise ValueError("side_touch_off_distance must be in range 0 to 30") - - if not 3 <= swap_speed <= 1000: - raise ValueError("swap_speed must be in range 3 to 1000") - - if not 0 <= settling_time <= 99: - raise ValueError("settling_time must be in range 0 to 99") - - if not 0 <= mix_volume <= 11500: - raise ValueError("mix_volume must be in range 0 to 11500") - - if not 0 <= mix_cycles <= 99: - raise ValueError("mix_cycles must be in range 0 to 99") - - if not 0 <= mix_position_in_z_direction_from_liquid_surface <= 990: - raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 990") - - if not 0 <= surface_following_distance_during_mixing <= 990: - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") - - if not 3 <= mix_speed <= 5000: - raise ValueError("mix_speed must be in range 3 to 5000") - - if not 0 <= limit_curve_index <= 999: - raise ValueError("limit_curve_index must be in range 0 to 999") - - if tadm_channel_pattern is None: - tadm_channel_pattern = [True] * 96 - elif not len(tadm_channel_pattern) < 24: - raise ValueError( - f"tadm_channel_pattern must be of length 24, but is '{len(tadm_channel_pattern)}'" - ) - tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) - - if not 0 <= tadm_algorithm_on_off <= 1: - raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") - - if not 0 <= recording_mode <= 2: - raise ValueError("recording_mode must be in range 0 to 2") - - return await self.send_command( - module="A1HM", - command="DD", - dm=type_of_dispensing_mode, - xp=x_position, - yp=y_position, - zx=minimum_height, - zu=tube_2nd_section_height_measured_from_zm, - zr=tube_2nd_section_ratio, - lp=lld_search_height, - zl=liquid_surface_at_function_without_lld, - po=pull_out_distance_to_take_transport_air_in_function_without_lld, - ip=immersion_depth, - fp=surface_following_distance, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, - dv=dispense_volume, - ds=dispense_speed, - ss=cut_off_speed, - rv=stop_back_volume, - ta=transport_air_volume, - ba=blow_out_air_volume, - lm=lld_mode, - ll=lld_sensitivity, - dj=side_touch_off_distance, - de=swap_speed, - wt=settling_time, - mv=mix_volume, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - mh=surface_following_distance_during_mixing, - ms=mix_speed, - gi=limit_curve_index, - cw=hex(tadm_channel_pattern_num)[2:].upper(), - gj=tadm_algorithm_on_off, - gk=recording_mode, + """Deprecated: use ``VantageHead96Backend._core96_dispensing_of_liquid``.""" + return await self._vantage_head96._core96_dispensing_of_liquid( + type_of_dispensing_mode=type_of_dispensing_mode, + x_position=x_position / 10, + y_position=y_position / 10, + minimum_height=minimum_height / 10, + tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm / 10, + tube_2nd_section_ratio=tube_2nd_section_ratio / 10, + lld_search_height=lld_search_height / 10, + liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld / 10, + pull_out_distance_to_take_transport_air_in_function_without_lld=pull_out_distance_to_take_transport_air_in_function_without_lld + / 10, + immersion_depth=immersion_depth / 10, + surface_following_distance=surface_following_distance / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, + dispense_volume=dispense_volume / 100, + dispense_speed=dispense_speed / 10, + cut_off_speed=cut_off_speed / 10, + stop_back_volume=stop_back_volume / 100, + transport_air_volume=transport_air_volume / 10, + blow_out_air_volume=blow_out_air_volume / 100, + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + side_touch_off_distance=side_touch_off_distance / 10, + swap_speed=swap_speed / 10, + settling_time=settling_time / 10, + mix_volume=mix_volume / 100, + mix_cycles=mix_cycles, + mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + mix_speed=mix_speed / 10, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, ) async def core96_tip_pick_up( @@ -4217,50 +2275,15 @@ async def core96_tip_pick_up( minimal_traverse_height_at_begin_of_command: int = 3900, minimal_height_at_command_end: int = 3900, ): - """Tip Pick up using the 96 head. - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - tip_type: Tip type (see command TT). - tip_handling_method: Tip handling method. - z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= tip_type <= 199: - raise ValueError("tip_type must be in range 0 to 199") - - if not 0 <= tip_handling_method <= 2: - raise ValueError("tip_handling_method must be in range 0 to 2") - - if not 0 <= z_deposit_position <= 3900: - raise ValueError("z_deposit_position must be in range 0 to 3900") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - return await self.send_command( - module="A1HM", - command="TP", - xp=x_position, - yp=y_position, - tt=tip_type, - td=tip_handling_method, - tz=z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + """Deprecated: use ``VantageHead96Backend._core96_tip_pick_up``.""" + return await self._vantage_head96._core96_tip_pick_up( + x_position=x_position / 10, + y_position=y_position / 10, + tip_type=tip_type, + tip_handling_method=tip_handling_method, + z_deposit_position=z_deposit_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, ) async def core96_tip_discard( @@ -4271,40 +2294,13 @@ async def core96_tip_discard( minimal_traverse_height_at_begin_of_command: int = 3900, minimal_height_at_command_end: int = 3900, ): - """Tip Discard using the 96 head. - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_deposit_position: Z deposit position [0.1mm] (collar bearing position). - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= z_deposit_position <= 3900: - raise ValueError("z_deposit_position must be in range 0 to 3900") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - return await self.send_command( - module="A1HM", - command="TR", - xp=x_position, - yp=y_position, - tz=z_deposit_position, - th=minimal_traverse_height_at_begin_of_command, - te=minimal_height_at_command_end, + """Deprecated: use ``VantageHead96Backend._core96_tip_discard``.""" + return await self._vantage_head96._core96_tip_discard( + x_position=x_position / 10, + y_position=y_position / 10, + z_deposit_position=z_deposit_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, ) async def core96_move_to_defined_position( @@ -4314,35 +2310,12 @@ async def core96_move_to_defined_position( z_position: int = 0, minimal_traverse_height_at_begin_of_command: int = 3900, ): - """Move to defined position using the 96 head. - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_position: Z Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - """ - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= z_position <= 3900: - raise ValueError("z_position must be in range 0 to 3900") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - return await self.send_command( - module="A1HM", - command="DN", - xp=x_position, - yp=y_position, - zp=z_position, - th=minimal_traverse_height_at_begin_of_command, + """Deprecated: use ``VantageHead96Backend._core96_move_to_defined_position``.""" + return await self._vantage_head96._core96_move_to_defined_position( + x_position=x_position / 10, + y_position=y_position / 10, + z_position=z_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, ) async def core96_wash_tips( @@ -4357,60 +2330,17 @@ async def core96_wash_tips( mix_cycles: int = 0, mix_speed: int = 2000, ): - """Wash tips on the 96 head. - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. - surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command - [0.1mm]. - mix_volume: Mix volume [0.1ul]. - mix_cycles: Mix cycles. - mix_speed: Mix speed [0.1ul/s]. - """ - - if not -500000 <= x_position <= 50000: - raise ValueError("x_position must be in range -500000 to 50000") - - if not 422 <= y_position <= 5921: - raise ValueError("y_position must be in range 422 to 5921") - - if not 0 <= liquid_surface_at_function_without_lld <= 3900: - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") - - if not 0 <= minimum_height <= 3900: - raise ValueError("minimum_height must be in range 0 to 3900") - - if not 0 <= surface_following_distance_during_mixing <= 990: - raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") - - if not 0 <= mix_volume <= 11500: - raise ValueError("mix_volume must be in range 0 to 11500") - - if not 0 <= mix_cycles <= 99: - raise ValueError("mix_cycles must be in range 0 to 99") - - if not 3 <= mix_speed <= 5000: - raise ValueError("mix_speed must be in range 3 to 5000") - - return await self.send_command( - module="A1HM", - command="DW", - xp=x_position, - yp=y_position, - zl=liquid_surface_at_function_without_lld, - zx=minimum_height, - mh=surface_following_distance_during_mixing, - th=minimal_traverse_height_at_begin_of_command, - mv=mix_volume, - mc=mix_cycles, - ms=mix_speed, + """Deprecated: use ``VantageHead96Backend._core96_wash_tips``.""" + return await self._vantage_head96._core96_wash_tips( + x_position=x_position / 10, + y_position=y_position / 10, + liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld / 10, + minimum_height=minimum_height / 10, + surface_following_distance_during_mixing=surface_following_distance_during_mixing / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, + mix_volume=mix_volume / 10, + mix_cycles=mix_cycles, + mix_speed=mix_speed / 10, ) async def core96_empty_washed_tips( @@ -4418,24 +2348,10 @@ async def core96_empty_washed_tips( liquid_surface_at_function_without_lld: int = 3900, minimal_height_at_command_end: int = 3900, ): - """Empty washed tips (end of wash procedure only) on the 96 head. - - Args: - liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not 0 <= liquid_surface_at_function_without_lld <= 3900: - raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") - - if not 0 <= minimal_height_at_command_end <= 3900: - raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") - - return await self.send_command( - module="A1HM", - command="EE", - zl=liquid_surface_at_function_without_lld, - te=minimal_height_at_command_end, + """Deprecated: use ``VantageHead96Backend._core96_empty_washed_tips``.""" + return await self._vantage_head96._core96_empty_washed_tips( + liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, ) async def core96_search_for_teach_in_signal_in_x_direction( @@ -4443,142 +2359,69 @@ async def core96_search_for_teach_in_signal_in_x_direction( x_search_distance: int = 0, x_speed: int = 50, ): - """Search for Teach in signal in X direction on the 96 head. - - Args: - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. - """ - - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - - if not 20 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 20 to 25000") - - return await self.send_command( - module="A1HM", - command="DL", - xs=x_search_distance, - xv=x_speed, + """Deprecated: use ``VantageHead96Backend._core96_search_for_teach_in_signal_in_x_direction``.""" + return await self._vantage_head96._core96_search_for_teach_in_signal_in_x_direction( + x_search_distance=x_search_distance / 10, + x_speed=x_speed / 10, ) async def core96_set_any_parameter(self): - """Set any parameter within the 96 head module.""" - - return await self.send_command( - module="A1HM", - command="AA", - ) + """Deprecated: use ``VantageHead96Backend._core96_set_any_parameter``.""" + return await self._vantage_head96._core96_set_any_parameter() async def core96_query_tip_presence(self): - """Query Tip presence on the 96 head.""" - - return await self.send_command( - module="A1HM", - command="QA", - ) + """Deprecated: use ``VantageHead96Backend._core96_query_tip_presence``.""" + return await self._vantage_head96._core96_query_tip_presence() async def core96_request_position(self): - """Request position of the 96 head.""" - - return await self.send_command( - module="A1HM", - command="QI", - ) + """Deprecated: use ``VantageHead96Backend._core96_request_position``.""" + return await self._vantage_head96._core96_request_position() async def core96_request_tadm_error_status( self, tadm_channel_pattern: Optional[List[bool]] = None, ): - """Request TADM error status on the 96 head. - - Args: - tadm_channel_pattern: TADM Channel pattern. - """ - - if tadm_channel_pattern is None: - tadm_channel_pattern = [True] * 96 - elif not len(tadm_channel_pattern) < 24: - raise ValueError( - f"tadm_channel_pattern must be of length 24, but is '{len(tadm_channel_pattern)}'" - ) - tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) - - return await self.send_command( - module="A1HM", - command="VB", - cw=hex(tadm_channel_pattern_num)[2:].upper(), + """Deprecated: use ``VantageHead96Backend._core96_request_tadm_error_status``.""" + return await self._vantage_head96._core96_request_tadm_error_status( + tadm_channel_pattern=tadm_channel_pattern, ) async def ipg_request_initialization_status(self) -> bool: - """Request initialization status of IPG. - - This command was based on the STAR command (QW) and the VStarTranslator log. A1AM corresponds - to "arm". - - Returns: - True if the ipg module is initialized, False otherwise. - """ - - resp = await self.send_command(module="A1RM", command="QW", fmt={"qw": "int"}) - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``IPGBackend.request_initialization_status``.""" + return await self._vantage_ipg.request_initialization_status() async def ipg_initialize(self): - """Initialize IPG""" - - return await self.send_command( - module="A1RM", - command="DI", - ) + """Deprecated: use ``IPGBackend.initialize``.""" + return await self._vantage_ipg.initialize() async def ipg_park(self): - """Park IPG""" - - return await self.send_command( - module="A1RM", - command="GP", - ) + """Deprecated: use ``IPGBackend.park``.""" + return await self._vantage_ipg.park() async def ipg_expose_channel_n(self): - """Expose channel n""" - - return await self.send_command( - module="A1RM", - command="DQ", - ) + """Deprecated: use ``IPGBackend.expose_channel_n``.""" + return await self._vantage_ipg.expose_channel_n() async def ipg_release_object(self): - """Release object""" - - return await self.send_command( - module="A1RM", - command="DO", - ) + """Deprecated: use ``IPGBackend.open_gripper``.""" + return await self._vantage_ipg.open_gripper(0) async def ipg_search_for_teach_in_signal_in_x_direction( self, x_search_distance: int = 0, x_speed: int = 50, ): - """Search for Teach in signal in X direction + """Deprecated: use ``IPGBackend.search_for_teach_in_signal_in_x_direction``. + + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. Args: x_search_distance: X search distance [0.1mm]. x_speed: X speed [0.1mm/s]. """ - - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - - if not 20 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 20 to 25000") - - return await self.send_command( - module="A1RM", - command="DL", - xs=x_search_distance, - xv=x_speed, + return await self._vantage_ipg.search_for_teach_in_signal_in_x_direction( + x_search_distance=x_search_distance / 10, + x_speed=x_speed / 10, ) async def ipg_grip_plate( @@ -4595,69 +2438,19 @@ async def ipg_grip_plate( hotel_depth: int = 0, minimal_height_at_command_end: int = 3600, ): - """Grip plate - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_position: Z Position [0.1mm]. - grip_strength: Grip strength (0 = low 99 = high). - open_gripper_position: Open gripper position [0.1mm]. - plate_width: Plate width [0.1mm]. - plate_width_tolerance: Plate width tolerance [0.1mm]. - acceleration_index: Acceleration index. - z_clearance_height: Z clearance height [0.1mm]. - hotel_depth: Hotel depth [0.1mm] (0 = Stack). - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - - if not -10000 <= y_position <= 10000: - raise ValueError("y_position must be in range -10000 to 10000") - - if not 0 <= z_position <= 4000: - raise ValueError("z_position must be in range 0 to 4000") - - if not 0 <= grip_strength <= 160: - raise ValueError("grip_strength must be in range 0 to 160") - - if not 0 <= open_gripper_position <= 9999: - raise ValueError("open_gripper_position must be in range 0 to 9999") - - if not 0 <= plate_width <= 9999: - raise ValueError("plate_width must be in range 0 to 9999") - - if not 0 <= plate_width_tolerance <= 99: - raise ValueError("plate_width_tolerance must be in range 0 to 99") - - if not 0 <= acceleration_index <= 4: - raise ValueError("acceleration_index must be in range 0 to 4") - - if not 0 <= z_clearance_height <= 999: - raise ValueError("z_clearance_height must be in range 0 to 999") - - if not 0 <= hotel_depth <= 3000: - raise ValueError("hotel_depth must be in range 0 to 3000") - - if not 0 <= minimal_height_at_command_end <= 4000: - raise ValueError("minimal_height_at_command_end must be in range 0 to 4000") - - return await self.send_command( - module="A1RM", - command="DG", - xp=x_position, - yp=y_position, - zp=z_position, - yw=grip_strength, - yo=open_gripper_position, - yg=plate_width, - pt=plate_width_tolerance, - ai=acceleration_index, - zc=z_clearance_height, - hd=hotel_depth, - te=minimal_height_at_command_end, + """Deprecated: use ``IPGBackend.grip_plate``.""" + return await self._vantage_ipg.grip_plate( + x_position=x_position / 10, + y_position=y_position / 10, + z_position=z_position / 10, + grip_strength=grip_strength, + open_gripper_position=open_gripper_position / 10, + plate_width=plate_width / 10, + plate_width_tolerance=plate_width_tolerance / 10, + acceleration_index=acceleration_index, + z_clearance_height=z_clearance_height / 10, + hotel_depth=hotel_depth / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, ) async def ipg_put_plate( @@ -4671,54 +2464,16 @@ async def ipg_put_plate( hotel_depth: int = 0, minimal_height_at_command_end: int = 3600, ): - """Put plate - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_position: Z Position [0.1mm]. - open_gripper_position: Open gripper position [0.1mm]. - z_clearance_height: Z clearance height [0.1mm]. - press_on_distance: Press on distance [0.1mm]. - hotel_depth: Hotel depth [0.1mm] (0 = Stack). - minimal_height_at_command_end: Minimal height at command end [0.1mm]. - """ - - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - - if not -10000 <= y_position <= 10000: - raise ValueError("y_position must be in range -10000 to 10000") - - if not 0 <= z_position <= 4000: - raise ValueError("z_position must be in range 0 to 4000") - - if not 0 <= open_gripper_position <= 9999: - raise ValueError("open_gripper_position must be in range 0 to 9999") - - if not 0 <= z_clearance_height <= 999: - raise ValueError("z_clearance_height must be in range 0 to 999") - - if not 0 <= press_on_distance <= 999: - raise ValueError("press_on_distance must be in range 0 to 999") - - if not 0 <= hotel_depth <= 3000: - raise ValueError("hotel_depth must be in range 0 to 3000") - - if not 0 <= minimal_height_at_command_end <= 4000: - raise ValueError("minimal_height_at_command_end must be in range 0 to 4000") - - return await self.send_command( - module="A1RM", - command="DR", - xp=x_position, - yp=y_position, - zp=z_position, - yo=open_gripper_position, - zc=z_clearance_height, - # zi=press_on_distance, # not sent? - hd=hotel_depth, - te=minimal_height_at_command_end, + """Deprecated: use ``IPGBackend.put_plate``.""" + return await self._vantage_ipg.put_plate( + x_position=x_position / 10, + y_position=y_position / 10, + z_position=z_position / 10, + open_gripper_position=open_gripper_position / 10, + z_clearance_height=z_clearance_height / 10, + press_on_distance=press_on_distance / 10, + hotel_depth=hotel_depth / 10, + minimal_height_at_command_end=minimal_height_at_command_end / 10, ) async def ipg_prepare_gripper_orientation( @@ -4726,25 +2481,10 @@ async def ipg_prepare_gripper_orientation( grip_orientation: int = 32, minimal_traverse_height_at_begin_of_command: int = 3600, ): - """Prepare gripper orientation - - Args: - grip_orientation: Grip orientation. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - """ - - if not 1 <= grip_orientation <= 44: - raise ValueError("grip_orientation must be in range 1 to 44") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 4000: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 4000") - - return await self.send_command( - module="A1RM", - command="GA", - gd=grip_orientation, - th=minimal_traverse_height_at_begin_of_command, + """Deprecated: use ``IPGBackend.prepare_gripper_orientation``.""" + return await self._vantage_ipg.prepare_gripper_orientation( + grip_orientation=grip_orientation, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, ) async def ipg_move_to_defined_position( @@ -4754,110 +2494,57 @@ async def ipg_move_to_defined_position( z_position: int = 3600, minimal_traverse_height_at_begin_of_command: int = 3600, ): - """Move to defined position - - Args: - x_position: X Position [0.1mm]. - y_position: Y Position [0.1mm]. - z_position: Z Position [0.1mm]. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of - command [0.1mm]. - """ - - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - - if not -10000 <= y_position <= 10000: - raise ValueError("y_position must be in range -10000 to 10000") - - if not 0 <= z_position <= 4000: - raise ValueError("z_position must be in range 0 to 4000") - - if not 0 <= minimal_traverse_height_at_begin_of_command <= 4000: - raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 4000") - - return await self.send_command( - module="A1RM", - command="DN", - xp=x_position, - yp=y_position, - zp=z_position, - th=minimal_traverse_height_at_begin_of_command, + """Deprecated: use ``IPGBackend.move_to_defined_position``.""" + return await self._vantage_ipg.move_to_defined_position( + x_position=x_position / 10, + y_position=y_position / 10, + z_position=z_position / 10, + minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command / 10, ) async def ipg_set_any_parameter_within_this_module(self): - """Set any parameter within this module""" - - return await self.send_command( - module="A1RM", - command="AA", - ) + """Deprecated: use ``IPGBackend.set_any_parameter_within_this_module``.""" + return await self._vantage_ipg.set_any_parameter_within_this_module() async def ipg_get_parking_status(self) -> bool: - """Get parking status. Returns `True` if parked.""" - - resp = await self.send_command(module="A1RM", command="RG", fmt={"rg": "int"}) - return resp is not None and resp["rg"] == 1 + """Deprecated: use ``IPGBackend.get_parking_status``.""" + return await self._vantage_ipg.get_parking_status() async def ipg_query_tip_presence(self): - """Query Tip presence""" - - return await self.send_command( - module="A1RM", - command="QA", - ) + """Deprecated: use ``IPGBackend.query_tip_presence``.""" + return await self._vantage_ipg.query_tip_presence() async def ipg_request_access_range(self, grip_orientation: int = 32): - """Request access range + """Deprecated: use ``IPGBackend.request_access_range``. Args: - grip_orientation: Grip orientation. + grip_orientation: Grip orientation (1-44). """ - - if not 1 <= grip_orientation <= 44: - raise ValueError("grip_orientation must be in range 1 to 44") - - return await self.send_command( - module="A1RM", - command="QR", - gd=grip_orientation, + return await self._vantage_ipg.request_access_range( + grip_orientation=grip_orientation, ) async def ipg_request_position(self, grip_orientation: int = 32): - """Request position + """Deprecated: use ``IPGBackend.request_position``. Args: - grip_orientation: Grip orientation. + grip_orientation: Grip orientation (1-44). """ - - if not 1 <= grip_orientation <= 44: - raise ValueError("grip_orientation must be in range 1 to 44") - - return await self.send_command( - module="A1RM", - command="QI", - gd=grip_orientation, + return await self._vantage_ipg.request_position( + grip_orientation=grip_orientation, ) async def ipg_request_actual_angular_dimensions(self): - """Request actual angular dimensions""" - - return await self.send_command( - module="A1RM", - command="RR", - ) + """Deprecated: use ``IPGBackend.request_actual_angular_dimensions``.""" + return await self._vantage_ipg.request_actual_angular_dimensions() async def ipg_request_configuration(self): - """Request configuration""" - - return await self.send_command( - module="A1RM", - command="RS", - ) + """Deprecated: use ``IPGBackend.request_configuration``.""" + return await self._vantage_ipg.request_configuration() async def x_arm_initialize(self): - """Initialize the x arm""" - return await self.send_command(module="A1XM", command="XI") + """Deprecated: use ``VantageXArm.initialize``.""" + return await self._vantage_x_arm.initialize() async def x_arm_move_to_x_position( self, @@ -4865,24 +2552,13 @@ async def x_arm_move_to_x_position( x_speed: int = 25000, TODO_XI_1: int = 1, ): - """Move arm to X position + """Deprecated: use ``VantageXArm.move_to``. - Args: - x_position: X Position [0.1mm]. - x_speed: X speed [0.1mm/s]. - TODO_XI_1: (0). + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. """ - - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") - - if not 1 <= TODO_XI_1 <= 25000: - raise ValueError("TODO_XI_1 must be in range 1 to 25000") - - return await self.send_command(module="A1XM", command="XP", xp=x_position, xv=x_speed) + return await self._vantage_x_arm.move_to( + x_position=x_position / 10, x_speed=x_speed / 10 + ) async def x_arm_move_to_x_position_with_all_attached_components_in_z_safety_position( self, @@ -4890,29 +2566,12 @@ async def x_arm_move_to_x_position_with_all_attached_components_in_z_safety_posi x_speed: int = 25000, TODO_XA_1: int = 1, ): - """Move arm to X position with all attached components in Z safety position + """Deprecated: use ``VantageXArm.move_to_safe``. - Args: - x_position: X Position [0.1mm]. - x_speed: X speed [0.1mm/s]. - TODO_XA_1: (0). + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. """ - - if not -50000 <= x_position <= 50000: - raise ValueError("x_position must be in range -50000 to 50000") - - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") - - if not 1 <= TODO_XA_1 <= 25000: - raise ValueError("TODO_XA_1 must be in range 1 to 25000") - - return await self.send_command( - module="A1XM", - command="XA", - xp=x_position, - xv=x_speed, - xx=TODO_XA_1, + return await self._vantage_x_arm.move_to_safe( + x_position=x_position / 10, x_speed=x_speed / 10, xx=TODO_XA_1 ) async def x_arm_move_arm_relatively_in_x( @@ -4921,29 +2580,12 @@ async def x_arm_move_arm_relatively_in_x( x_speed: int = 25000, TODO_XS_1: int = 1, ): - """Move arm relatively in X + """Deprecated: use ``VantageXArm.move_relatively``. - Args: - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. - TODO_XS_1: (0). + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. """ - - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") - - if not 1 <= TODO_XS_1 <= 25000: - raise ValueError("TODO_XS_1 must be in range 1 to 25000") - - return await self.send_command( - module="A1XM", - command="XS", - xs=x_search_distance, - xv=x_speed, - xx=TODO_XS_1, + return await self._vantage_x_arm.move_relatively( + x_search_distance=x_search_distance / 10, x_speed=x_speed / 10, xx=TODO_XS_1 ) async def x_arm_search_x_for_teach_signal( @@ -4952,162 +2594,65 @@ async def x_arm_search_x_for_teach_signal( x_speed: int = 25000, TODO_XT_1: int = 1, ): - """Search X for teach signal + """Deprecated: use ``VantageXArm.search_teach_signal``. - Args: - x_search_distance: X search distance [0.1mm]. - x_speed: X speed [0.1mm/s]. - TODO_XT_1: (0). + Note: this legacy method accepts values in 0.1mm and converts to mm for the new API. """ - - if not -50000 <= x_search_distance <= 50000: - raise ValueError("x_search_distance must be in range -50000 to 50000") - - if not 1 <= x_speed <= 25000: - raise ValueError("x_speed must be in range 1 to 25000") - - if not 1 <= TODO_XT_1 <= 25000: - raise ValueError("TODO_XT_1 must be in range 1 to 25000") - - return await self.send_command( - module="A1XM", - command="XT", - xs=x_search_distance, - xv=x_speed, - xx=TODO_XT_1, + return await self._vantage_x_arm.search_teach_signal( + x_search_distance=x_search_distance / 10, x_speed=x_speed / 10, xx=TODO_XT_1 ) async def x_arm_set_x_drive_angle_of_alignment( self, TODO_XL_1: int = 1, ): - """Set X drive angle of alignment - - Args: - TODO_XL_1: (0). - """ - - if not 1 <= TODO_XL_1 <= 1: - raise ValueError("TODO_XL_1 must be in range 1 to 1") - - return await self.send_command( - module="A1XM", - command="XL", - xl=TODO_XL_1, - ) + """Deprecated: use ``VantageXArm.set_x_drive_angle_of_alignment``.""" + return await self._vantage_x_arm.set_x_drive_angle_of_alignment(xl=TODO_XL_1) async def x_arm_turn_x_drive_off(self): - return await self.send_command(module="A1XM", command="XO") + """Deprecated: use ``VantageXArm.turn_off``.""" + return await self._vantage_x_arm.turn_off() async def x_arm_send_message_to_motion_controller( self, TODO_BD_1: str = "", ): - """Send message to motion controller - - Args: - TODO_BD_1: (0). - """ - - return await self.send_command( - module="A1XM", - command="BD", - bd=TODO_BD_1, - ) + """Deprecated: use ``VantageXArm.send_message_to_motion_controller``.""" + return await self._vantage_x_arm.send_message_to_motion_controller(bd=TODO_BD_1) async def x_arm_set_any_parameter_within_this_module( self, TODO_AA_1: int = 0, TODO_AA_2: int = 1, ): - """Set any parameter within this module - - Args: - TODO_AA_1: (0). - TODO_AA_2: (0). - """ - - return await self.send_command( - module="A1XM", - command="AA", - xm=TODO_AA_1, - xt=TODO_AA_2, + """Deprecated: use ``VantageXArm.set_any_parameter_within_this_module``.""" + return await self._vantage_x_arm.set_any_parameter_within_this_module( + xm=TODO_AA_1, xt=TODO_AA_2 ) async def x_arm_request_arm_x_position(self): - """Request arm X position. This returns a list, of which the first value is one that can be - used with x_arm_move_to_x_position.""" - return await self.send_command(module="A1XM", command="RX") + """Deprecated: use ``VantageXArm.request_position``.""" + return await self._vantage_x_arm.request_position() async def x_arm_request_error_code(self): - """X arm request error code""" - return await self.send_command(module="A1XM", command="RE") + """Deprecated: use ``VantageXArm.request_error_code``.""" + return await self._vantage_x_arm.request_error_code() async def x_arm_request_x_drive_recorded_data( self, TODO_QL_1: int = 0, TODO_QL_2: int = 0, ): - """Request X drive recorded data - - Args: - TODO_QL_1: (0). - TODO_QL_2: (0). - """ - - return await self.send_command( - module="A1RM", - command="QL", - lj=TODO_QL_1, - ln=TODO_QL_2, - ) + """Deprecated: use ``VantageXArm.request_x_drive_recorded_data``.""" + return await self._vantage_x_arm.request_x_drive_recorded_data(lj=TODO_QL_1, ln=TODO_QL_2) async def disco_mode(self): - """Easter egg.""" - for _ in range(69): - r, g, b = ( - random.randint(30, 100), - random.randint(30, 100), - random.randint(30, 100), - ) - await self.set_led_color("on", intensity=100, white=0, red=r, green=g, blue=b, uv=0) - await asyncio.sleep(0.1) + """Deprecated: use ``VantageDriver.disco_mode``.""" + await self.driver.disco_mode() async def russian_roulette(self): - """Dangerous easter egg.""" - sure = input( - "Are you sure you want to play Russian Roulette? This will turn on the uv-light " - "with a probability of 1/6. (yes/no) " - ) - if sure.lower() != "yes": - print("boring") - return - - if random.randint(1, 6) == 6: - await self.set_led_color( - "on", - intensity=100, - white=100, - red=100, - green=0, - blue=0, - uv=100, - ) - print("You lost.") - else: - await self.set_led_color("on", intensity=100, white=100, red=0, green=100, blue=0, uv=0) - print("You won.") - - await asyncio.sleep(5) - await self.set_led_color( - "on", - intensity=100, - white=100, - red=100, - green=100, - blue=100, - uv=0, - ) + """Deprecated: use ``VantageDriver.russian_roulette``.""" + await self.driver.russian_roulette() # Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py index 3ca80f942bb..006e22668b3 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py @@ -211,7 +211,12 @@ class VantageCommandCatcher(VantageBackend): def __init__(self): super().__init__() - self.commands = [] + self.commands: List[str] = [] + + # Replace the real driver with a chatterbox so delegated methods work without USB. + from pylabrobot.hamilton.liquid_handlers.vantage.chatterbox import VantageChatterboxDriver + + self.driver = VantageChatterboxDriver() async def setup(self) -> None: # type: ignore self.setup_finished = True @@ -220,6 +225,22 @@ async def setup(self) -> None: # type: ignore self._num_arms = 1 self._head96_installed = True + # Set up the chatterbox driver so delegated methods work. + await self.driver.setup() + + # Route the driver's send_command back to our command list so tests can inspect commands. + catcher = self + + async def _catching_send_command(module, command, auto_id=True, tip_pattern=None, + write_timeout=None, read_timeout=None, wait=True, + fmt=None, **kwargs): + cmd, _ = catcher.driver._assemble_command( + module=module, command=command, auto_id=auto_id, tip_pattern=tip_pattern, **kwargs + ) + catcher.commands.append(cmd) + + self.driver.send_command = _catching_send_command # type: ignore[method-assign] + async def send_command( self, module: str, From f519e52f7cf246299d51c507175fc2e9cd272c03 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 12:38:42 -0700 Subject: [PATCH 05/11] Fix list multiplication bug, greedy regex, fmt mutation, docstring examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from Copilot review: - list(mth or [th]) * len(ops) duplicated user-provided lists — use conditional instead - fw_parsing greedy regex (.*) over-captures across quoted fields — use ([^"]*) - fw_parsing mutated caller's fmt dict by inserting "id" — copy first - Docstring examples had mismatched keys and missing id prefix Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/vantage/fw_parsing.py | 12 +++++++----- .../liquid_handlers/vantage/pip_backend.py | 16 ++++++++-------- .../vantage/tests/fw_parsing_tests.py | 5 +++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py b/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py index 80c4e058470..4d1f2350f88 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/fw_parsing.py @@ -18,11 +18,11 @@ def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dic - ``"hex"``: a hexadecimal number Example: - >>> parse_vantage_fw_string("id0xs30 -100 +1 1000", {"id": "int", "x": "[int]"}) - {"id": 0, "x": [30, -100, 1, 1000]} + >>> parse_vantage_fw_string("id0xs30 -100 +1 1000", {"xs": "[int]"}) + {"id": 0, "xs": [30, -100, 1, 1000]} - >>> parse_vantage_fw_string('es"error string"', {"es": "str"}) - {"es": "error string"} + >>> parse_vantage_fw_string('id0es"error string"', {"es": "str"}) + {"id": 0, "es": "error string"} """ parsed: dict = {} @@ -33,6 +33,8 @@ def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dic if not isinstance(fmt, dict): raise TypeError(f"invalid fmt for fmt: expected dict, got {type(fmt)}") + fmt = dict(fmt) # copy to avoid mutating the caller's dict + if "id" not in fmt: fmt["id"] = "int" @@ -43,7 +45,7 @@ def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dic raise ValueError(f"Expected exactly one match for {key} in {s}") parsed[key] = int(matches[0]) elif data_type == "str": - matches = re.findall(rf"{key}\"(.*)\"", s) + matches = re.findall(rf'{key}"([^"]*)"', s) if len(matches) != 1: raise ValueError(f"Expected exactly one match for {key} in {s}") parsed[key] = matches[0] diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py index 5590b7994da..adaa478c21c 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -572,8 +572,8 @@ async def pick_up_tips( tip_type=ttti, begin_z_deposit_position=[max_z + max_total_tip_length] * len(ops), end_z_deposit_position=[max_z + max_tip_length] * len(ops), - minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), - minimal_height_at_command_end=list(mhe or [th]) * len(ops), + minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), + minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), tip_handling_method=[1] * len(ops), blow_out_air_volume=[0] * len(ops), ) @@ -618,8 +618,8 @@ async def drop_tips( tip_pattern=channels_involved, begin_z_deposit_position=[max_z + 10] * len(ops), end_z_deposit_position=[max_z] * len(ops), - minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), - minimal_height_at_command_end=list(mhe or [th]) * len(ops), + minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), + minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), tip_handling_method=[0] * len(ops), ) except VantageFirmwareError as e: @@ -708,8 +708,8 @@ async def aspirate( y_position=y_positions, type_of_aspiration=backend_params.type_of_aspiration or [0] * len(ops), tip_pattern=channels_involved, - minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), - minimal_height_at_command_end=list(mhe or [th]) * len(ops), + minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), + minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), lld_search_height=lld_search_heights, clot_detection_height=list(backend_params.clot_detection_height or [0] * len(ops)), liquid_surface_at_function_without_lld=liquid_surfaces_no_lld, @@ -850,8 +850,8 @@ async def dispense( backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) ), tube_2nd_section_ratio=list(backend_params.tube_2nd_section_ratio or [0] * len(ops)), - minimal_traverse_height_at_begin_of_command=list(mth or [th]) * len(ops), - minimal_height_at_command_end=list(mhe or [th]) * len(ops), + minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), + minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), dispense_volume=volumes, dispense_speed=flow_rates, cut_off_speed=list(backend_params.cut_off_speed or [250] * len(ops)), diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py b/pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py index 487bdbcedda..d4a7ea12d90 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/tests/fw_parsing_tests.py @@ -40,6 +40,11 @@ def test_no_match_raises(self): with self.assertRaises(ValueError): parse_vantage_fw_string("id0", {"qw": "int"}) + def test_fmt_not_mutated(self): + fmt = {"qw": "int"} + parse_vantage_fw_string("id0qw1", fmt) + self.assertEqual(fmt, {"qw": "int"}) # "id" should not have been added + if __name__ == "__main__": unittest.main() From 6ea0a004cb9bfa7e4f14e00d0cc0f4e27f7ef4d3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 13:04:18 -0700 Subject: [PATCH 06/11] Fix unit conversion bugs: pre_wetting/mix/stop_back volume and tube ratio Found by adversarial review agents cross-referencing against legacy firmware parameter validation ranges: - pre_wetting_volume (oa): firmware uses 0.1uL, was *100, fixed to *10 - mix_volume (mv): firmware uses 0.1uL, was *100, fixed to *10 - stop_back_volume (rv): firmware uses 0.1uL, was *100, fixed to *10 - tube_2nd_section_ratio (zr): dimensionless, was *10, fixed to no multiplier Affected: _pip_aspirate, _pip_dispense, _core96_aspiration_of_liquid, _core96_dispensing_of_liquid, _core96_wash_tips, and their legacy delegations. The DM command (simultaneous_aspiration_dispensation) already had these correct. Also: document that open_gripper ignores gripper_width (IPG only supports full release). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/vantage/head96_backend.py | 14 +++++------ .../hamilton/liquid_handlers/vantage/ipg.py | 3 ++- .../liquid_handlers/vantage/pip_backend.py | 22 +++++++++-------- .../backends/hamilton/vantage_backend.py | 24 +++++++++---------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py index 519cc15c2a5..ddf180c33fd 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -773,19 +773,19 @@ async def _core96_aspiration_of_liquid( po=round(pull_out_distance_to_take_transport_air_in_function_without_lld * 10), zx=round(minimum_height * 10), zu=round(tube_2nd_section_height_measured_from_zm * 10), - zr=round(tube_2nd_section_ratio * 10), + zr=round(tube_2nd_section_ratio), ip=round(immersion_depth * 10), fp=round(surface_following_distance * 10), av=round(aspiration_volume * 100), as_=round(aspiration_speed * 10), ta=round(transport_air_volume * 10), ba=round(blow_out_air_volume * 100), - oa=round(pre_wetting_volume * 100), + oa=round(pre_wetting_volume * 10), lm=lld_mode, ll=lld_sensitivity, de=round(swap_speed * 10), wt=round(settling_time * 10), - mv=round(mix_volume * 100), + mv=round(mix_volume * 10), mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, mh=surface_following_distance_during_mixing, @@ -849,7 +849,7 @@ async def _core96_dispensing_of_liquid( yp=round(y_position * 10), zx=round(minimum_height * 10), zu=round(tube_2nd_section_height_measured_from_zm * 10), - zr=round(tube_2nd_section_ratio * 10), + zr=round(tube_2nd_section_ratio), lp=round(lld_search_height * 10), zl=round(liquid_surface_at_function_without_lld * 10), po=round(pull_out_distance_to_take_transport_air_in_function_without_lld * 10), @@ -860,7 +860,7 @@ async def _core96_dispensing_of_liquid( dv=round(dispense_volume * 100), ds=round(dispense_speed * 10), ss=round(cut_off_speed * 10), - rv=round(stop_back_volume * 100), + rv=round(stop_back_volume * 10), ta=round(transport_air_volume * 10), ba=round(blow_out_air_volume * 100), lm=lld_mode, @@ -868,7 +868,7 @@ async def _core96_dispensing_of_liquid( dj=round(side_touch_off_distance * 10), de=round(swap_speed * 10), wt=round(settling_time * 10), - mv=round(mix_volume * 100), + mv=round(mix_volume * 10), mc=mix_cycles, mp=mix_position_in_z_direction_from_liquid_surface, mh=surface_following_distance_during_mixing, @@ -937,7 +937,7 @@ async def _core96_wash_tips( zx=round(minimum_height * 10), mh=round(surface_following_distance_during_mixing * 10), th=round(minimal_traverse_height_at_begin_of_command * 10), - mv=round(mix_volume * 100), + mv=round(mix_volume * 10), mc=mix_cycles, ms=round(mix_speed * 10), ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py index 9450e7d57f4..76ab9477158 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py @@ -175,7 +175,8 @@ async def request_gripper_location( async def open_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: - """Release object (A1RM:DO).""" + """Release object (A1RM:DO). The ``gripper_width`` parameter is ignored — the IPG + only supports fully opening the gripper.""" await self.driver.send_command(module="A1RM", command="DO") async def close_gripper( diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py index adaa478c21c..0e44ffbc198 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -1069,13 +1069,14 @@ async def _pip_aspirate( fw_ip = [round(v * 10) for v in immersion_depth] fw_fp = [round(v * 10) for v in surface_following_distance] fw_zo = [round(v * 10) for v in aspirate_position_above_z_touch_off] - # tube_2nd_section_ratio: ratio x10 for firmware - fw_zr = [round(v * 10) for v in tube_2nd_section_ratio] - # Volumes: uL -> 0.01uL (x100) + # tube_2nd_section_ratio: dimensionless, already in firmware units + fw_zr = [round(v) for v in tube_2nd_section_ratio] + # Volumes: uL -> 0.01uL (x100) for aspiration_volume and blow_out_air_volume fw_av = [round(v * 100) for v in aspiration_volume] fw_ba = [round(v * 100) for v in blow_out_air_volume] - fw_oa = [round(v * 100) for v in pre_wetting_volume] - fw_mv = [round(v * 100) for v in mix_volume] + # Volumes: uL -> 0.1uL (x10) for pre_wetting_volume and mix_volume + fw_oa = [round(v * 10) for v in pre_wetting_volume] + fw_mv = [round(v * 10) for v in mix_volume] # Transport air volume: uL -> 0.1uL (x10) fw_ta = [round(v * 10) for v in transport_air_volume] # Speeds: uL/s -> 0.1uL/s (x10) @@ -1212,13 +1213,14 @@ async def _pip_dispense( fw_te = [round(v * 10) for v in minimal_height_at_command_end] fw_zo = [round(v * 10) for v in dispense_position_above_z_touch_off] fw_dj = round(side_touch_off_distance * 10) - # tube_2nd_section_ratio: ratio x10 for firmware - fw_zr = [round(v * 10) for v in tube_2nd_section_ratio] - # Volumes: uL -> 0.01uL (x100) + # tube_2nd_section_ratio: dimensionless, already in firmware units + fw_zr = [round(v) for v in tube_2nd_section_ratio] + # Volumes: uL -> 0.01uL (x100) for dispense_volume and blow_out_air_volume fw_dv = [round(v * 100) for v in dispense_volume] - fw_rv = [round(v * 100) for v in stop_back_volume] fw_ba = [round(v * 100) for v in blow_out_air_volume] - fw_mv = [round(v * 100) for v in mix_volume] + # Volumes: uL -> 0.1uL (x10) for stop_back_volume and mix_volume + fw_rv = [round(v * 10) for v in stop_back_volume] + fw_mv = [round(v * 10) for v in mix_volume] # Transport air volume: uL -> 0.1uL (x10) fw_ta = [round(v * 10) for v in transport_air_volume] # Speeds: uL/s -> 0.1uL/s (x10) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index 90e862250c2..113eb5d3e4f 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -1109,7 +1109,7 @@ async def pip_aspirate( tube_2nd_section_height_measured_from_zm=[ v / 10 for v in tube_2nd_section_height_measured_from_zm ], - tube_2nd_section_ratio=[v / 10 for v in tube_2nd_section_ratio], + tube_2nd_section_ratio=tube_2nd_section_ratio, minimum_height=[v / 10 for v in minimum_height], immersion_depth=[v / 10 for v in immersion_depth], surface_following_distance=[v / 10 for v in surface_following_distance], @@ -1117,14 +1117,14 @@ async def pip_aspirate( aspiration_speed=[v / 10 for v in aspiration_speed], transport_air_volume=[v / 10 for v in transport_air_volume], blow_out_air_volume=[v / 100 for v in blow_out_air_volume], - pre_wetting_volume=[v / 100 for v in pre_wetting_volume], + pre_wetting_volume=[v / 10 for v in pre_wetting_volume], lld_mode=lld_mode, lld_sensitivity=lld_sensitivity, pressure_lld_sensitivity=pressure_lld_sensitivity, aspirate_position_above_z_touch_off=[v / 10 for v in aspirate_position_above_z_touch_off], swap_speed=[v / 10 for v in swap_speed], settling_time=[v / 10 for v in settling_time], - mix_volume=[v / 100 for v in mix_volume], + mix_volume=[v / 10 for v in mix_volume], mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, mix_speed=[v / 10 for v in mix_speed], @@ -1259,7 +1259,7 @@ async def pip_dispense( tube_2nd_section_height_measured_from_zm=[ v / 10 for v in tube_2nd_section_height_measured_from_zm ], - tube_2nd_section_ratio=[v / 10 for v in tube_2nd_section_ratio], + tube_2nd_section_ratio=tube_2nd_section_ratio, minimal_traverse_height_at_begin_of_command=[ v / 10 for v in minimal_traverse_height_at_begin_of_command ], @@ -1267,7 +1267,7 @@ async def pip_dispense( dispense_volume=[v / 100 for v in dispense_volume], dispense_speed=[v / 10 for v in dispense_speed], cut_off_speed=[v / 10 for v in cut_off_speed], - stop_back_volume=[v / 100 for v in stop_back_volume], + stop_back_volume=[v / 10 for v in stop_back_volume], transport_air_volume=[v / 10 for v in transport_air_volume], blow_out_air_volume=[v / 100 for v in blow_out_air_volume], lld_mode=lld_mode, @@ -1277,7 +1277,7 @@ async def pip_dispense( pressure_lld_sensitivity=pressure_lld_sensitivity, swap_speed=[v / 10 for v in swap_speed], settling_time=[v / 10 for v in settling_time], - mix_volume=[v / 100 for v in mix_volume], + mix_volume=[v / 10 for v in mix_volume], mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, mix_speed=[v / 10 for v in mix_speed], @@ -2168,19 +2168,19 @@ async def core96_aspiration_of_liquid( / 10, minimum_height=minimum_height / 10, tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm / 10, - tube_2nd_section_ratio=tube_2nd_section_ratio / 10, + tube_2nd_section_ratio=tube_2nd_section_ratio, immersion_depth=immersion_depth / 10, surface_following_distance=surface_following_distance / 10, aspiration_volume=aspiration_volume / 100, aspiration_speed=aspiration_speed / 10, transport_air_volume=transport_air_volume / 10, blow_out_air_volume=blow_out_air_volume / 100, - pre_wetting_volume=pre_wetting_volume / 100, + pre_wetting_volume=pre_wetting_volume / 10, lld_mode=lld_mode, lld_sensitivity=lld_sensitivity, swap_speed=swap_speed / 10, settling_time=settling_time / 10, - mix_volume=mix_volume / 100, + mix_volume=mix_volume / 10, mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, surface_following_distance_during_mixing=surface_following_distance_during_mixing, @@ -2234,7 +2234,7 @@ async def core96_dispensing_of_liquid( y_position=y_position / 10, minimum_height=minimum_height / 10, tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm / 10, - tube_2nd_section_ratio=tube_2nd_section_ratio / 10, + tube_2nd_section_ratio=tube_2nd_section_ratio, lld_search_height=lld_search_height / 10, liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld / 10, pull_out_distance_to_take_transport_air_in_function_without_lld=pull_out_distance_to_take_transport_air_in_function_without_lld @@ -2246,7 +2246,7 @@ async def core96_dispensing_of_liquid( dispense_volume=dispense_volume / 100, dispense_speed=dispense_speed / 10, cut_off_speed=cut_off_speed / 10, - stop_back_volume=stop_back_volume / 100, + stop_back_volume=stop_back_volume / 10, transport_air_volume=transport_air_volume / 10, blow_out_air_volume=blow_out_air_volume / 100, lld_mode=lld_mode, @@ -2254,7 +2254,7 @@ async def core96_dispensing_of_liquid( side_touch_off_distance=side_touch_off_distance / 10, swap_speed=swap_speed / 10, settling_time=settling_time / 10, - mix_volume=mix_volume / 100, + mix_volume=mix_volume / 10, mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, surface_following_distance_during_mixing=surface_following_distance_during_mixing, From fd3fbd9843ec2d797dedaf443723ae8f028c73d8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 13:22:09 -0700 Subject: [PATCH 07/11] Fix or-falsy bugs and restore dropped parameters - Replace 28 instances of `x or default` with `x if x is not None else default` where x is Optional[float] and 0.0 is a legitimate value. Most impactful: settling_time=0.0 no longer silently becomes 5s, blow_out_air_volume=0.0 no longer gets liquid class default. Affects pip_backend, head96_backend, core gripper, and driver. - Restore dropped params in discard_core_gripper_tool: tip_type, begin_z_deposit_position, end_z_deposit_position now forwarded. - Restore dropped first_pip_channel_node_no in release_object/open_gripper. - Fix blink_interval=0 silently becoming 750ms. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/vantage/core.py | 67 ++++++++--- .../liquid_handlers/vantage/driver.py | 2 +- .../liquid_handlers/vantage/head96_backend.py | 108 +++++++++++++----- .../liquid_handlers/vantage/pip_backend.py | 24 +++- .../backends/hamilton/vantage_backend.py | 13 ++- 5 files changed, 165 insertions(+), 49 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/core.py b/pylabrobot/hamilton/liquid_handlers/vantage/core.py index a4594d99dac..60971bab983 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/core.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/core.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional from pylabrobot.capabilities.arms.backend import GripperArmBackend from pylabrobot.capabilities.arms.standard import GripperLocation @@ -123,9 +123,15 @@ async def pick_up_at_location( acceleration_index=backend_params.acceleration_index, grip_strength=backend_params.grip_strength, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=backend_params.minimal_height_at_command_end or th, ) async def drop_at_location( @@ -155,9 +161,15 @@ async def drop_at_location( z_speed=backend_params.z_speed, open_gripper_position=open_pos, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=backend_params.minimal_height_at_command_end or th, ) async def move_to_location( @@ -182,15 +194,26 @@ async def move_to_location( z_position=location.z, z_speed=backend_params.z_speed, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th ), ) async def open_gripper( - self, gripper_width: float, backend_params: Optional[BackendParams] = None + self, + gripper_width: float, + backend_params: Optional[BackendParams] = None, + first_pip_channel_node_no: int = 1, ) -> None: - """Release the gripped object (A1PM:DO).""" - await self.driver.send_command(module="A1PM", command="DO", pa=1) + """Release the gripped object (A1PM:DO). + + Args: + gripper_width: Ignored for CoRe gripper (width is not controllable on release). + backend_params: Optional backend params (unused). + first_pip_channel_node_no: First (lower) pip channel node number (1-16). Default 1. + """ + await self.driver.send_command(module="A1PM", command="DO", pa=first_pip_channel_node_no) async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None @@ -322,6 +345,9 @@ async def discard_tool( self, x_position: float = 0.0, first_gripper_tool_y_pos: float = 300.0, + tip_type: Optional[List[int]] = None, + begin_z_deposit_position: Optional[List[float]] = None, + end_z_deposit_position: Optional[List[float]] = None, first_pip_channel_node_no: int = 1, minimal_traverse_height_at_begin_of_command: float = 360.0, minimal_height_at_command_end: float = 360.0, @@ -331,19 +357,32 @@ async def discard_tool( Args: x_position: Gripper tool X position [mm]. first_gripper_tool_y_pos: First (lower channel) CoRe gripper tool Y position [mm]. + tip_type: Tip type per channel. Default ``[4] * num_channels``. + begin_z_deposit_position: Begin Z deposit position per channel [mm]. + Default ``[0.0] * num_channels``. + end_z_deposit_position: End Z deposit position per channel [mm]. + Default ``[0.0] * num_channels``. first_pip_channel_node_no: First (lower) pip channel node number (1-16). minimal_traverse_height_at_begin_of_command: Minimal traverse height [mm]. minimal_height_at_command_end: Minimal height at command end [mm]. """ + n = self.driver.num_channels + if tip_type is None: + tip_type = [4] * n + if begin_z_deposit_position is None: + begin_z_deposit_position = [0.0] * n + if end_z_deposit_position is None: + end_z_deposit_position = [0.0] * n + await self.driver.send_command( module="A1PM", command="DJ", xa=round(x_position * 10), yj=round(first_gripper_tool_y_pos * 10), - tt=[4] * self.driver.num_channels, - tp=[0] * self.driver.num_channels, - tz=[0] * self.driver.num_channels, - th=[round(minimal_traverse_height_at_begin_of_command * 10)] * self.driver.num_channels, + tt=tip_type, + tp=[round(v * 10) for v in begin_z_deposit_position], + tz=[round(v * 10) for v in end_z_deposit_position], + th=[round(minimal_traverse_height_at_begin_of_command * 10)] * n, pa=first_pip_channel_node_no, - te=[round(minimal_height_at_command_end * 10)] * self.driver.num_channels, + te=[round(minimal_height_at_command_end * 10)] * n, ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py index 63375f21062..2b51a79036a 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py @@ -423,7 +423,7 @@ async def set_led_color( command="LI", li={"on": 1, "off": 0, "blink": 2}[mode], os=intensity, - ok=blink_interval or 750, + ok=blink_interval if blink_interval is not None else 750, ol=f"{white} {red} {green} {blue} {uv}", ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py index ddf180c33fd..eb0f8e015b9 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -336,9 +336,15 @@ async def pick_up_tips96( tip_handling_method=backend_params.tip_handling_method, z_deposit_position=backend_params.z_deposit_position + pickup.offset.z, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -381,9 +387,15 @@ async def drop_tips96( y_position=position.y, z_deposit_position=backend_params.z_deposit_position + drop.offset.z, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -444,7 +456,9 @@ async def aspirate96( well_bottoms = position.z lld_search_height = well_bottoms + aspiration.container.get_absolute_size_z() + 1.7 - liquid_height = position.z + (aspiration.liquid_height or 0) + liquid_height = position.z + ( + aspiration.liquid_height if aspiration.liquid_height is not None else 0 + ) tip = next(t for t in aspiration.tips if t is not None) hlc = backend_params.hlc @@ -464,18 +478,30 @@ async def aspirate96( else: volume = hlc.compute_corrected_volume(aspiration.volume) - transport_air_volume = backend_params.transport_air_volume or ( - hlc.aspiration_air_transport_volume if hlc is not None else 0 + transport_air_volume = ( + backend_params.transport_air_volume + if backend_params.transport_air_volume is not None + else (hlc.aspiration_air_transport_volume if hlc is not None else 0) ) - blow_out_air_volume = backend_params.blow_out_air_volume or ( - hlc.aspiration_blow_out_volume if hlc is not None else 0 + blow_out_air_volume = ( + backend_params.blow_out_air_volume + if backend_params.blow_out_air_volume is not None + else (hlc.aspiration_blow_out_volume if hlc is not None else 0) ) - flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) - swap_speed = backend_params.swap_speed or ( - hlc.aspiration_swap_speed if hlc is not None else 100 + flow_rate = ( + aspiration.flow_rate + if aspiration.flow_rate is not None + else (hlc.aspiration_flow_rate if hlc is not None else 250) ) - settling_time = backend_params.settling_time or ( - hlc.aspiration_settling_time if hlc is not None else 5 + swap_speed = ( + backend_params.swap_speed + if backend_params.swap_speed is not None + else (hlc.aspiration_swap_speed if hlc is not None else 100) + ) + settling_time = ( + backend_params.settling_time + if backend_params.settling_time is not None + else (hlc.aspiration_settling_time if hlc is not None else 5) ) th = self.driver.traversal_height @@ -486,9 +512,15 @@ async def aspirate96( x_position=position.x, y_position=position.y, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), lld_search_height=lld_search_height, liquid_surface_at_function_without_lld=liquid_height, pull_out_distance_to_take_transport_air_in_function_without_lld=( @@ -579,7 +611,9 @@ async def dispense96( lld_search_height = well_bottoms + dispense.container.get_absolute_size_z() + 1.7 # +10mm offset on dispense liquid height. Ported from legacy. Not present on aspirate or STAR. - liquid_height = position.z + (dispense.liquid_height or 0) + 10 + liquid_height = ( + position.z + (dispense.liquid_height if dispense.liquid_height is not None else 0) + 10 + ) tip = next(t for t in dispense.tips if t is not None) hlc = backend_params.hlc @@ -607,16 +641,30 @@ async def dispense96( blow_out=backend_params.blow_out, ) - transport_air_volume = backend_params.transport_air_volume or ( - hlc.dispense_air_transport_volume if hlc is not None else 0 + transport_air_volume = ( + backend_params.transport_air_volume + if backend_params.transport_air_volume is not None + else (hlc.dispense_air_transport_volume if hlc is not None else 0) + ) + blow_out_air_volume = ( + backend_params.blow_out_air_volume + if backend_params.blow_out_air_volume is not None + else (hlc.dispense_blow_out_volume if hlc is not None else 0) ) - blow_out_air_volume = backend_params.blow_out_air_volume or ( - hlc.dispense_blow_out_volume if hlc is not None else 0 + flow_rate = ( + dispense.flow_rate + if dispense.flow_rate is not None + else (hlc.dispense_flow_rate if hlc is not None else 250) ) - flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) - swap_speed = backend_params.swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) - settling_time = backend_params.settling_time or ( - hlc.dispense_settling_time if hlc is not None else 5 + swap_speed = ( + backend_params.swap_speed + if backend_params.swap_speed is not None + else (hlc.dispense_swap_speed if hlc is not None else 100) + ) + settling_time = ( + backend_params.settling_time + if backend_params.settling_time is not None + else (hlc.dispense_settling_time if hlc is not None else 5) ) th = self.driver.traversal_height @@ -639,9 +687,15 @@ async def dispense96( immersion_depth=backend_params.immersion_depth, surface_following_distance=backend_params.surface_following_distance, minimal_traverse_height_at_begin_of_command=( - backend_params.minimal_traverse_height_at_begin_of_command or th + backend_params.minimal_traverse_height_at_begin_of_command + if backend_params.minimal_traverse_height_at_begin_of_command is not None + else th + ), + minimal_height_at_command_end=( + backend_params.minimal_height_at_command_end + if backend_params.minimal_height_at_command_end is not None + else th ), - minimal_height_at_command_end=(backend_params.minimal_height_at_command_end or th), dispense_volume=volume, dispense_speed=flow_rate, cut_off_speed=backend_params.cut_off_speed, diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py index 0e44ffbc198..89b1192a407 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -682,7 +682,8 @@ async def aspirate( for op in ops ] liquid_surfaces_no_lld = backend_params.liquid_surface_at_function_without_lld or [ - wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops) + wb + (op.liquid_height if op.liquid_height is not None else 0) + for wb, op in zip(well_bottoms, ops) ] lld_search_heights = backend_params.lld_search_height or [ wb + op.resource.get_absolute_size_z() + (1.7 if isinstance(op.resource, Well) else 5) @@ -690,11 +691,15 @@ async def aspirate( ] flow_rates = [ - op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) + op.flow_rate + if op.flow_rate is not None + else (hlc.aspiration_flow_rate if hlc is not None else 100) for op, hlc in zip(ops, hlcs) ] blow_out_air_volumes = [ - op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0) + op.blow_out_air_volume + if op.blow_out_air_volume is not None + else (hlc.dispense_blow_out_volume if hlc is not None else 0) for op, hlc in zip(ops, hlcs) ] @@ -806,18 +811,25 @@ async def dispense( op.resource.get_absolute_location(z="b").z + op.offset.z + op.resource.material_z_thickness for op in ops ] - liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + liquid_surfaces_no_lld = [ + wb + (op.liquid_height if op.liquid_height is not None else 0) + for wb, op in zip(well_bottoms, ops) + ] lld_search_heights = backend_params.lld_search_height or [ wb + op.resource.get_absolute_size_z() + (1.7 if isinstance(op.resource, Well) else 5) for wb, op in zip(well_bottoms, ops) ] flow_rates = [ - op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100) + op.flow_rate + if op.flow_rate is not None + else (hlc.dispense_flow_rate if hlc is not None else 100) for op, hlc in zip(ops, hlcs) ] blow_out_air_volumes = [ - op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0) + op.blow_out_air_volume + if op.blow_out_air_volume is not None + else (hlc.dispense_blow_out_volume if hlc is not None else 0) for op, hlc in zip(ops, hlcs) ] diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index 113eb5d3e4f..edad0182373 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -1945,6 +1945,12 @@ async def discard_core_gripper_tool( ): """Deprecated: delegates to VantageCoreGripper.discard_tool. Use that instead.""" + if tip_type is None: + tip_type = [4] * self.num_channels + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels if minimal_traverse_height_at_begin_of_command is None: minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels if minimal_height_at_command_end is None: @@ -1953,6 +1959,9 @@ async def discard_core_gripper_tool( return await self._vantage_core_gripper.discard_tool( x_position=gripper_tool_x_position / 10, first_gripper_tool_y_pos=first_gripper_tool_y_pos / 10, + tip_type=tip_type, + begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position], + end_z_deposit_position=[v / 10 for v in end_z_deposit_position], first_pip_channel_node_no=first_pip_channel_node_no, minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command[0] / 10, @@ -2051,7 +2060,9 @@ async def release_object( ): """Deprecated: delegates to VantageCoreGripper.open_gripper. Use that instead.""" - return await self._vantage_core_gripper.open_gripper(0) + return await self._vantage_core_gripper.open_gripper( + 0, first_pip_channel_node_no=first_pip_channel_node_no + ) async def set_any_parameter_within_this_module(self): """Deprecated: delegates to VantagePIPBackend.set_any_parameter_within_this_module.""" From 4d1e857bd0bf362c9dac384fe58a732a5fb3481c Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 13:44:23 -0700 Subject: [PATCH 08/11] Fix open_gripper interface violation, use BackendParams for channel node VantageCoreGripper.open_gripper had an extra parameter violating the CanGrip interface. Moved first_pip_channel_node_no into OpenGripperParams so the method signature matches the abstract interface exactly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/vantage/core.py | 25 +++++++++++-------- .../backends/hamilton/vantage_backend.py | 8 ++++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/core.py b/pylabrobot/hamilton/liquid_handlers/vantage/core.py index 60971bab983..5ed93cbecaf 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/core.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/core.py @@ -200,20 +200,25 @@ async def move_to_location( ), ) - async def open_gripper( - self, - gripper_width: float, - backend_params: Optional[BackendParams] = None, - first_pip_channel_node_no: int = 1, - ) -> None: - """Release the gripped object (A1PM:DO). + @dataclass + class OpenGripperParams(BackendParams): + """Vantage-specific parameters for CoRe gripper release. Args: - gripper_width: Ignored for CoRe gripper (width is not controllable on release). - backend_params: Optional backend params (unused). first_pip_channel_node_no: First (lower) pip channel node number (1-16). Default 1. """ - await self.driver.send_command(module="A1PM", command="DO", pa=first_pip_channel_node_no) + + first_pip_channel_node_no: int = 1 + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Release the gripped object (A1PM:DO). The ``gripper_width`` parameter is ignored.""" + if not isinstance(backend_params, VantageCoreGripper.OpenGripperParams): + backend_params = VantageCoreGripper.OpenGripperParams() + await self.driver.send_command( + module="A1PM", command="DO", pa=backend_params.first_pip_channel_node_no + ) async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index edad0182373..2b00a220a42 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -2058,10 +2058,14 @@ async def release_object( self, first_pip_channel_node_no: int = 1, ): - """Deprecated: delegates to VantageCoreGripper.open_gripper. Use that instead.""" + """Deprecated: use ``VantageCoreGripper.open_gripper``.""" + from pylabrobot.hamilton.liquid_handlers.vantage.core import VantageCoreGripper return await self._vantage_core_gripper.open_gripper( - 0, first_pip_channel_node_no=first_pip_channel_node_no + 0, + backend_params=VantageCoreGripper.OpenGripperParams( + first_pip_channel_node_no=first_pip_channel_node_no + ), ) async def set_any_parameter_within_this_module(self): From 1f7c582316a92302139e89ab93659238f68ae3be Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 16:16:49 -0700 Subject: [PATCH 09/11] Add core_grippers context manager, raise NotImplementedError for unknowns - Add core_grippers() context manager to Vantage device following STAR pattern. Raises NotImplementedError until the tool pickup firmware command is reverse-engineered. - VantageCoreGripper: close_gripper, halt, park, is_gripper_closed, request_gripper_location all raise NotImplementedError with clear messages instead of silently doing nothing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/vantage/core.py | 14 +++++--- .../liquid_handlers/vantage/vantage.py | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/core.py b/pylabrobot/hamilton/liquid_handlers/vantage/core.py index 5ed93cbecaf..ad4bc9ad9ff 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/core.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/core.py @@ -223,16 +223,22 @@ async def open_gripper( async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: - pass # Grip happens in pick_up_at_location. + raise NotImplementedError( + "close_gripper is not supported for VantageCoreGripper. " + "Gripping happens inside pick_up_at_location." + ) async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: - raise NotImplementedError() + raise NotImplementedError("is_gripper_closed is not implemented for VantageCoreGripper.") async def halt(self, backend_params: Optional[BackendParams] = None) -> None: - pass + raise NotImplementedError("halt is not implemented for VantageCoreGripper.") async def park(self, backend_params: Optional[BackendParams] = None) -> None: - pass # Tool management (pick up / return) is handled by the Vantage device class. + raise NotImplementedError( + "park is not supported for VantageCoreGripper. " + "Tool return is handled by the Vantage.core_grippers() context manager." + ) async def request_gripper_location( self, backend_params: Optional[BackendParams] = None diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py index 11a6e58126c..e07d9c5e2f7 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py @@ -1,7 +1,9 @@ """Vantage device: wires VantageDriver backends to PIP/Head96/IPG capability frontends.""" -from typing import Optional +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional +from pylabrobot.capabilities.arms.arm import GripperArm from pylabrobot.capabilities.arms.orientable_arm import OrientableArm from pylabrobot.capabilities.liquid_handling.head96 import Head96 from pylabrobot.capabilities.liquid_handling.pip import PIP @@ -98,3 +100,35 @@ async def stop(self): self._setup_finished = False self.head96 = None self.ipg = None + + # -- CoRe grippers --------------------------------------------------------- + + @asynccontextmanager + async def core_grippers( + self, + front_channel: int = 7, + traversal_height: float = 245.0, + ) -> AsyncIterator[GripperArm]: + """Context manager that picks up CoRe gripper tools on enter and returns them on exit. + + Usage:: + + async with vantage.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) + + Args: + front_channel: The front (higher-numbered) PIP channel to mount the gripper on. + The back channel is ``front_channel - 1``. Default 7 (channels 6+7). + traversal_height: Minimum Z clearance in mm for safe lateral movement. Default 245.0. + """ + raise NotImplementedError( + "CoRe gripper tool pickup on Vantage has not been reverse-engineered yet. " + "On the STAR this is C0:ZT; the Vantage equivalent is unknown. " + "If you figure out the command, please contribute it." + ) + # Once pickup is implemented, the pattern should be: + # 1. Park IPG if out (shared X drive) + # 2. Pick up gripper tools (unknown firmware command) + # 3. yield GripperArm(VantageCoreGripper(driver), deck, grip_axis="y") + # 4. In finally: return tools via discard_tool (A1PM:DJ) + yield # unreachable, but needed for asynccontextmanager typing From 1aa51eef079bb853889416f4957c42295882fe05 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 16 Apr 2026 16:11:06 -0700 Subject: [PATCH 10/11] Fix Vantage subsystem lifecycle, restore dropped mix params, inline PIP helpers - Lifecycle: pip/head96/ipg removed from VantageDriver._subsystems; the Vantage device's capability frontends drive their _on_setup/_on_stop. Legacy VantageBackend.setup/stop now calls those hooks explicitly so the legacy path still initializes the hardware. Vantage.stop runs unconditionally so a partial setup still releases the USB connection. Vantage.setup gains skip_loading_cover/skip_core96/skip_ipg knobs and forwards them to the driver. - Mix params: restore mix_position_in_z_direction_from_liquid_surface, surface_following_distance_during_mixing, and TODO_DA_5/TODO_DD_2 as BackendParams fields on PIP + Head96 aspirate/dispense, with firmware unit conversion at the send_command boundary; legacy wrapper forwards them. - PIP helpers inlined: tip pickup/drop and aspirate/dispense paths now call driver.send_command directly with explicit parameter-range validation instead of going through private helper wrappers. - IPG safety: default grip_strength lowered 100 -> 81 to avoid crushing thin-skirted labware. press_on_distance (zi) accepted for API compatibility but no longer forwarded to firmware since the parameter is uncharacterised on real hardware. - Polish: NotImplementedError stubs on VantageCoreGripper and IPGBackend gain docstrings explaining why each is unported (implicit in another command, uncharacterised, etc.). Chatterbox comment rewritten to explain why _on_setup is skipped on subsystems. VantageXArm exported. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../liquid_handlers/vantage/__init__.py | 1 + .../liquid_handlers/vantage/chatterbox.py | 8 +- .../hamilton/liquid_handlers/vantage/core.py | 27 + .../liquid_handlers/vantage/driver.py | 14 +- .../liquid_handlers/vantage/head96_backend.py | 20 +- .../hamilton/liquid_handlers/vantage/ipg.py | 36 +- .../liquid_handlers/vantage/pip_backend.py | 1140 +++++++++-------- .../liquid_handlers/vantage/vantage.py | 32 +- .../backends/hamilton/vantage_backend.py | 637 ++++++--- 9 files changed, 1138 insertions(+), 777 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py index c2a906a35dd..d2aedb725d7 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py @@ -1,3 +1,4 @@ from .chatterbox import VantageChatterboxDriver from .driver import VantageDriver from .vantage import Vantage +from .x_arm import VantageXArm diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py index c99319aa2bf..a41e71ae313 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py @@ -66,9 +66,11 @@ async def setup( self.x_arm = VantageXArm(driver=self) self.loading_cover = VantageLoadingCover(driver=self) if not skip_loading_cover else None - # Skip _on_setup() on subsystems: send_command returns None in chatterbox mode, - # which would cause status-query methods (e.g. query_tip_presence) to crash. - # Subsystems are already in a usable state since all firmware commands are no-ops. + # _on_setup() is deliberately not called on the subsystems here. Real-driver + # hooks issue status-query firmware commands (e.g. query_tip_presence) that + # would fail because chatterbox's send_command returns None instead of a + # parsed response dict. All firmware commands through this driver are + # logged-and-dropped, so the subsystems never need real initialization. async def stop(self): """Stop the chatterbox driver and clear subsystem state. diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/core.py b/pylabrobot/hamilton/liquid_handlers/vantage/core.py index ad4bc9ad9ff..b7b521a09cb 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/core.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/core.py @@ -223,18 +223,40 @@ async def open_gripper( async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: + """Close the CoRe gripper to the specified width. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend — gripping happens + implicitly inside pick_up_at_location (A1PM:DG), not via a standalone command. + """ raise NotImplementedError( "close_gripper is not supported for VantageCoreGripper. " "Gripping happens inside pick_up_at_location." ) async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Return whether the CoRe gripper is currently closed. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError("is_gripper_closed is not implemented for VantageCoreGripper.") async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Halt the CoRe gripper mid-motion. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError("halt is not implemented for VantageCoreGripper.") async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the CoRe gripper (return tools to rack). + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend — tool return is + handled by the Vantage.core_grippers() context manager exit, not a direct call. + """ raise NotImplementedError( "park is not supported for VantageCoreGripper. " "Tool return is handled by the Vantage.core_grippers() context manager." @@ -243,6 +265,11 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> GripperLocation: + """Return the current CoRe gripper location from firmware state. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError("request_gripper_location is not implemented for VantageCoreGripper.") # -- Firmware commands (A1PM) ---------------------------------------------- diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py index 2b51a79036a..c4fd0b4b380 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py @@ -249,14 +249,14 @@ async def setup( @property def _subsystems(self) -> List[Any]: - """All active subsystems, for lifecycle management.""" + """Non-capability subsystems owned directly by the driver. + + ``pip``, ``head96``, and ``ipg`` are intentionally excluded: their lifecycle + (``_on_setup`` / ``_on_stop``) is driven by the capability frontends on the + :class:`Vantage` device. Including them here would initialize each backend + twice — once from :meth:`setup` and once from ``Vantage.setup()``. + """ subs: List[Any] = [] - if self.pip is not None: - subs.append(self.pip) - if self.head96 is not None: - subs.append(self.head96) - if self.ipg is not None: - subs.append(self.ipg) if self.x_arm is not None: subs.append(self.x_arm) if self.loading_cover is not None: diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py index eb0f8e015b9..c71951bf3a3 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -200,6 +200,8 @@ class (aspirate from above the liquid surface). Default False. lld_sensitivity: int = 4 swap_speed: Optional[float] = None settling_time: Optional[float] = None + mix_position_in_z_direction_from_liquid_surface: float = 0.0 + surface_following_distance_during_mixing: float = 0.0 limit_curve_index: int = 0 tadm_channel_pattern: Optional[List[bool]] = None tadm_algorithm_on_off: int = 0 @@ -288,6 +290,8 @@ class (dispense from above the liquid surface). Default False. side_touch_off_distance: float = 0 swap_speed: Optional[float] = None settling_time: Optional[float] = None + mix_position_in_z_direction_from_liquid_surface: float = 0.0 + surface_following_distance_during_mixing: float = 0.0 limit_curve_index: int = 0 tadm_channel_pattern: Optional[List[bool]] = None tadm_algorithm_on_off: int = 0 @@ -544,8 +548,12 @@ async def aspirate96( settling_time=settling_time, mix_volume=aspiration.mix.volume if aspiration.mix is not None else 0, mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, - mix_position_in_z_direction_from_liquid_surface=0, - surface_following_distance_during_mixing=0, + mix_position_in_z_direction_from_liquid_surface=round( + backend_params.mix_position_in_z_direction_from_liquid_surface * 10 + ), + surface_following_distance_during_mixing=round( + backend_params.surface_following_distance_during_mixing * 10 + ), mix_speed=aspiration.mix.flow_rate if aspiration.mix is not None else 2.0, limit_curve_index=backend_params.limit_curve_index, tadm_channel_pattern=backend_params.tadm_channel_pattern, @@ -709,8 +717,12 @@ async def dispense96( settling_time=settling_time, mix_volume=dispense.mix.volume if dispense.mix is not None else 0, mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, - mix_position_in_z_direction_from_liquid_surface=0, - surface_following_distance_during_mixing=0, + mix_position_in_z_direction_from_liquid_surface=round( + backend_params.mix_position_in_z_direction_from_liquid_surface * 10 + ), + surface_following_distance_during_mixing=round( + backend_params.surface_following_distance_during_mixing * 10 + ), mix_speed=dispense.mix.flow_rate if dispense.mix is not None else 1.0, limit_curve_index=backend_params.limit_curve_index, tadm_channel_pattern=backend_params.tadm_channel_pattern, diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py index 76ab9477158..e75df3a4be1 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py @@ -60,7 +60,8 @@ class PickUpParams(BackendParams): """Vantage IPG-specific parameters for gripping a plate. Args: - grip_strength: Grip strength (0-160). Default 100. + grip_strength: Grip strength (0-160). Default 81 — the raw firmware default + of 100 risks crushing thin-skirted, PCR, and magnetic-rack labware. plate_width_tolerance: Plate width tolerance [mm]. Default 2.0. acceleration_index: Acceleration index (0-4). Default 4. z_clearance_height: Z clearance height [mm]. Default 5.0. @@ -68,7 +69,7 @@ class PickUpParams(BackendParams): minimal_height_at_command_end: Minimum Z height at command end [mm]. Default 360.0. """ - grip_strength: int = 100 + grip_strength: int = 81 plate_width_tolerance: float = 2.0 acceleration_index: int = 4 z_clearance_height: float = 5.0 @@ -81,7 +82,8 @@ class DropParams(BackendParams): Args: z_clearance_height: Z clearance height [mm]. Default 5.0. - press_on_distance: Press-on distance [mm]. Default 0.5. + press_on_distance: Accepted for API compatibility but not forwarded to the + firmware — see ``put_plate``. Default 0.5. hotel_depth: Hotel depth [mm] (0 = stack mode). Default 0. minimal_height_at_command_end: Minimum Z height at command end [mm]. Default 360.0. """ @@ -158,6 +160,11 @@ async def move_to_location( ) async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Halt the IPG mid-motion. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError("halt is not implemented for the Vantage IPG.") async def park(self, backend_params: Optional[BackendParams] = None) -> None: @@ -168,6 +175,11 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> GripperLocation: + """Return the current gripper location from firmware state. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError( "request_gripper_location is not yet implemented for the Vantage IPG." ) @@ -182,9 +194,20 @@ async def open_gripper( async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: + """Close the IPG to the specified width. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend — the IPG closes + implicitly during pick_up_at_location (A1RM:DG), not via a standalone command. + """ raise NotImplementedError("close_gripper is not implemented for the Vantage IPG.") async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Return whether the IPG gripper is currently closed. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend. + """ raise NotImplementedError("is_gripper_closed is not implemented for the Vantage IPG.") # -- Initialization and status --------------------------------------------- @@ -230,7 +253,7 @@ async def grip_plate( x_position: float, y_position: float, z_position: float, - grip_strength: int = 100, + grip_strength: int = 81, open_gripper_position: float = 86.0, plate_width: float = 80.0, plate_width_tolerance: float = 2.0, @@ -289,10 +312,12 @@ async def put_plate( z_position: Z position [mm]. open_gripper_position: Open gripper position [mm]. z_clearance_height: Z clearance height [mm]. - press_on_distance: Press-on distance [mm]. + press_on_distance: Accepted for API compatibility but not forwarded to the + firmware — the ``zi`` parameter is uncharacterised on real hardware. hotel_depth: Hotel depth [mm] (0 = stack). minimal_height_at_command_end: Minimal height at command end [mm]. """ + del press_on_distance await self.driver.send_command( module="A1RM", command="DR", @@ -301,7 +326,6 @@ async def put_plate( zp=round(z_position * 10), yo=round(open_gripper_position * 10), zc=round(z_clearance_height * 10), - zi=round(press_on_distance * 10), hd=round(hotel_depth * 10), te=round(minimal_height_at_command_end * 10), ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py index 89b1192a407..ad83676c8b6 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -409,6 +409,9 @@ class AspirateParams(BackendParams): aspirate_position_above_z_touch_off: Optional[List[float]] = None swap_speed: Optional[List[float]] = None settling_time: Optional[List[float]] = None + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None + surface_following_distance_during_mixing: Optional[List[float]] = None + TODO_DA_5: Optional[List[int]] = None capacitive_mad_supervision_on_off: Optional[List[int]] = None pressure_mad_supervision_on_off: Optional[List[int]] = None tadm_algorithm_on_off: int = 0 @@ -514,6 +517,9 @@ class DispenseParams(BackendParams): pressure_lld_sensitivity: Optional[List[int]] = None swap_speed: Optional[List[float]] = None settling_time: Optional[List[float]] = None + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None + surface_following_distance_during_mixing: Optional[List[float]] = None + TODO_DD_2: Optional[List[int]] = None tadm_algorithm_on_off: int = 0 limit_curve_index: Optional[List[int]] = None recording_mode: int = 0 @@ -564,18 +570,48 @@ async def pick_up_tips( mth = backend_params.minimal_traverse_height_at_begin_of_command mhe = backend_params.minimal_height_at_command_end + begin_z_deposit_position = [max_z + max_total_tip_length] * len(ops) + end_z_deposit_position = [max_z + max_tip_length] * len(ops) + minimal_traverse_height_at_begin_of_command = mth if mth is not None else [th] * len(ops) + minimal_height_at_command_end = mhe if mhe is not None else [th] * len(ops) + tip_handling_method = [1] * len(ops) + blow_out_air_volume = [0] * len(ops) + + if not all(0 <= x <= 50000 for x in x_positions): + raise ValueError("x_position must be in range 0 to 50000") + if not all(0 <= x <= 6500 for x in y_positions): + raise ValueError("y_position must be in range 0 to 6500") + if not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + if not all(0 <= x <= 199 for x in ttti): + raise ValueError("tip_type must be in range 0 to 199") + if not all(0 <= x <= 360.0 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 1250.0 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 9 for x in tip_handling_method): + raise ValueError("tip_handling_method must be in range 0 to 9") + try: - await self._pip_tip_pick_up( - x_position=x_positions, - y_position=y_positions, - tip_pattern=tip_pattern, - tip_type=ttti, - begin_z_deposit_position=[max_z + max_total_tip_length] * len(ops), - end_z_deposit_position=[max_z + max_tip_length] * len(ops), - minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), - minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), - tip_handling_method=[1] * len(ops), - blow_out_air_volume=[0] * len(ops), + await self.driver.send_command( + module="A1PM", + command="TP", + xp=x_positions, + yp=y_positions, + tm=tip_pattern, + tt=ttti, + tp=[round(z * 10) for z in begin_z_deposit_position], + tz=[round(z * 10) for z in end_z_deposit_position], + th=[round(h * 10) for h in minimal_traverse_height_at_begin_of_command], + te=[round(h * 10) for h in minimal_height_at_command_end], + ba=[round(v * 100) for v in blow_out_air_volume], + td=tip_handling_method, ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -611,16 +647,45 @@ async def drop_tips( mth = backend_params.minimal_traverse_height_at_begin_of_command mhe = backend_params.minimal_height_at_command_end + begin_z_deposit_position = [max_z + 10] * len(ops) + end_z_deposit_position = [max_z] * len(ops) + minimal_traverse_height_at_begin_of_command = mth if mth is not None else [th] * len(ops) + minimal_height_at_command_end = mhe if mhe is not None else [th] * len(ops) + tip_handling_method = [0] * len(ops) + TODO_TR_2 = 0 + + if not all(0 <= x <= 50000 for x in x_positions): + raise ValueError("x_position must be in range 0 to 50000") + if not all(0 <= x <= 6500 for x in y_positions): + raise ValueError("y_position must be in range 0 to 6500") + if not all(0 <= x <= 360.0 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 1 for x in channels_involved): + raise ValueError("tip_pattern must be in range 0 to 1") + if not -1000 <= TODO_TR_2 <= 1000: + raise ValueError("TODO_TR_2 must be in range -1000 to 1000") + if not all(0 <= x <= 9 for x in tip_handling_method): + raise ValueError("tip_handling_method must be in range 0 to 9") + try: - await self._pip_tip_discard( - x_position=x_positions, - y_position=y_positions, - tip_pattern=channels_involved, - begin_z_deposit_position=[max_z + 10] * len(ops), - end_z_deposit_position=[max_z] * len(ops), - minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), - minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), - tip_handling_method=[0] * len(ops), + await self.driver.send_command( + module="A1PM", + command="TR", + xp=x_positions, + yp=y_positions, + tp=[round(z * 10) for z in begin_z_deposit_position], + tz=[round(z * 10) for z in end_z_deposit_position], + th=[round(h * 10) for h in minimal_traverse_height_at_begin_of_command], + te=[round(h * 10) for h in minimal_height_at_command_end], + tm=channels_involved, + ts=TODO_TR_2, + td=tip_handling_method, ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -707,60 +772,179 @@ async def aspirate( mth = backend_params.minimal_traverse_height_at_begin_of_command mhe = backend_params.minimal_height_at_command_end + # Flatten all aspirate parameters into local names for guards + send_command. + type_of_aspiration = backend_params.type_of_aspiration or [0] * len(ops) + minimal_traverse_height_at_begin_of_command = mth if mth is not None else [th] * len(ops) + minimal_height_at_command_end = mhe if mhe is not None else [th] * len(ops) + clot_detection_height = list(backend_params.clot_detection_height or [0] * len(ops)) + pull_out_distance_to_take_transport_air_in_function_without_lld = list( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + or [10.9] * len(ops) + ) + tube_2nd_section_height_measured_from_zm = list( + backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ) + tube_2nd_section_ratio = list(backend_params.tube_2nd_section_ratio or [0] * len(ops)) + minimum_height = list(backend_params.minimum_height or well_bottoms) + immersion_depth = list(backend_params.immersion_depth or [0] * len(ops)) + surface_following_distance = list(backend_params.surface_following_distance or [0] * len(ops)) + transport_air_volume = list( + backend_params.transport_air_volume + or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ) + pre_wetting_volume = list(backend_params.pre_wetting_volume or [0] * len(ops)) + lld_mode = backend_params.lld_mode or [0] * len(ops) + lld_sensitivity = backend_params.lld_sensitivity or [4] * len(ops) + pressure_lld_sensitivity = backend_params.pressure_lld_sensitivity or [4] * len(ops) + aspirate_position_above_z_touch_off = list( + backend_params.aspirate_position_above_z_touch_off or [0.5] * len(ops) + ) + swap_speed = list(backend_params.swap_speed or [2] * len(ops)) + settling_time = list(backend_params.settling_time or [1] * len(ops)) + mix_volume = [op.mix.volume if op.mix is not None else 0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_in_z_direction_from_liquid_surface = ( + list(backend_params.mix_position_in_z_direction_from_liquid_surface) + if backend_params.mix_position_in_z_direction_from_liquid_surface is not None + else [0] * len(ops) + ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 250 for op in ops] + surface_following_distance_during_mixing = ( + list(backend_params.surface_following_distance_during_mixing) + if backend_params.surface_following_distance_during_mixing is not None + else [0] * len(ops) + ) + TODO_DA_5 = backend_params.TODO_DA_5 + capacitive_mad_supervision_on_off = ( + backend_params.capacitive_mad_supervision_on_off or [0] * len(ops) + ) + pressure_mad_supervision_on_off = ( + backend_params.pressure_mad_supervision_on_off or [0] * len(ops) + ) + tadm_algorithm_on_off = backend_params.tadm_algorithm_on_off + limit_curve_index = backend_params.limit_curve_index or [0] * len(ops) + recording_mode = backend_params.recording_mode + + if not all(0 <= x <= 2 for x in type_of_aspiration): + raise ValueError("type_of_aspiration must be in range 0 to 2") + if not all(0 <= x <= 1 for x in channels_involved): + raise ValueError("tip_pattern must be in range 0 to 1") + if not all(0 <= x <= 50000 for x in x_positions): + raise ValueError("x_position must be in range 0 to 50000") + if not all(0 <= x <= 6500 for x in y_positions): + raise ValueError("y_position must be in range 0 to 6500") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in lld_search_heights): + raise ValueError("lld_search_height must be in range 0 to 360.0") + if not all(0 <= x <= 50.0 for x in clot_detection_height): + raise ValueError("clot_detection_height must be in range 0 to 50.0") + if not all(0 <= x <= 360.0 for x in liquid_surfaces_no_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 360.0") + if not all( + 0 <= x <= 360.0 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 360.0" + ) + if not all(0 <= x <= 360.0 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 360.0") + if not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + if not all(0 <= x <= 360.0 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 360.0") + if not all(-360.0 <= x <= 360.0 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -360.0 to 360.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 360.0") + if not all(0 <= x <= 1250.0 for x in volumes): + raise ValueError("aspiration_volume must be in range 0 to 1250.0") + if not all(1.0 <= x <= 1000.0 for x in flow_rates): + raise ValueError("aspiration_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 50.0 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 50.0") + if not all(0 <= x <= 1250.0 for x in blow_out_air_volumes): + raise ValueError("blow_out_air_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 99.9 for x in pre_wetting_volume): + raise ValueError("pre_wetting_volume must be in range 0 to 99.9") + if not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + if not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + if not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + if not all(0 <= x <= 10.0 for x in aspirate_position_above_z_touch_off): + raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 10.0") + if not all(0.3 <= x <= 160.0 for x in swap_speed): + raise ValueError("swap_speed must be in range 0.3 to 160.0") + if not all(0 <= x <= 9.9 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 9.9") + if not all(0 <= x <= 1250.0 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + if not all(0 <= x <= 90.0 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 90.0") + if not all(1.0 <= x <= 1000.0 for x in mix_speed): + raise ValueError("mix_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 360.0") + if TODO_DA_5 is not None and not all(0 <= x <= 1 for x in TODO_DA_5): + raise ValueError("TODO_DA_5 must be in range 0 to 1") + if not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): + raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") + if not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): + raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + if not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + try: - await self._pip_aspirate( - x_position=x_positions, - y_position=y_positions, - type_of_aspiration=backend_params.type_of_aspiration or [0] * len(ops), - tip_pattern=channels_involved, - minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), - minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), - lld_search_height=lld_search_heights, - clot_detection_height=list(backend_params.clot_detection_height or [0] * len(ops)), - liquid_surface_at_function_without_lld=liquid_surfaces_no_lld, - pull_out_distance_to_take_transport_air_in_function_without_lld=list( - backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld - or [10.9] * len(ops) - ), - tube_2nd_section_height_measured_from_zm=list( - backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ), - tube_2nd_section_ratio=list(backend_params.tube_2nd_section_ratio or [0] * len(ops)), - minimum_height=list(backend_params.minimum_height or well_bottoms), - immersion_depth=list(backend_params.immersion_depth or [0] * len(ops)), - surface_following_distance=list( - backend_params.surface_following_distance or [0] * len(ops) - ), - aspiration_volume=volumes, - aspiration_speed=flow_rates, - transport_air_volume=list( - backend_params.transport_air_volume - or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ), - blow_out_air_volume=blow_out_air_volumes, - pre_wetting_volume=list(backend_params.pre_wetting_volume or [0] * len(ops)), - lld_mode=backend_params.lld_mode or [0] * len(ops), - lld_sensitivity=backend_params.lld_sensitivity or [4] * len(ops), - pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [4] * len(ops), - aspirate_position_above_z_touch_off=list( - backend_params.aspirate_position_above_z_touch_off or [0.5] * len(ops) - ), - swap_speed=list(backend_params.swap_speed or [2] * len(ops)), - settling_time=list(backend_params.settling_time or [1] * len(ops)), - mix_volume=[op.mix.volume if op.mix is not None else 0 for op in ops], - mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], - mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), - mix_speed=[op.mix.flow_rate if op.mix is not None else 250 for op in ops], - surface_following_distance_during_mixing=[0] * len(ops), - capacitive_mad_supervision_on_off=( - backend_params.capacitive_mad_supervision_on_off or [0] * len(ops) - ), - pressure_mad_supervision_on_off=( - backend_params.pressure_mad_supervision_on_off or [0] * len(ops) - ), - tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, - limit_curve_index=backend_params.limit_curve_index or [0] * len(ops), - recording_mode=backend_params.recording_mode, + await self.driver.send_command( + module="A1PM", + command="DA", + at=type_of_aspiration, + tm=channels_involved, + xp=x_positions, + yp=y_positions, + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + te=[round(v * 10) for v in minimal_height_at_command_end], + lp=[round(v * 10) for v in lld_search_heights], + ch=[round(v * 10) for v in clot_detection_height], + zl=[round(v * 10) for v in liquid_surfaces_no_lld], + po=[round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld], + zu=[round(v * 10) for v in tube_2nd_section_height_measured_from_zm], + zr=[round(v) for v in tube_2nd_section_ratio], + zx=[round(v * 10) for v in minimum_height], + ip=[round(v * 10) for v in immersion_depth], + fp=[round(v * 10) for v in surface_following_distance], + av=[round(v * 100) for v in volumes], + as_=[round(v * 10) for v in flow_rates], + ta=[round(v * 10) for v in transport_air_volume], + ba=[round(v * 100) for v in blow_out_air_volumes], + oa=[round(v * 10) for v in pre_wetting_volume], + lm=lld_mode, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + zo=[round(v * 10) for v in aspirate_position_above_z_touch_off], + de=[round(v * 10) for v in swap_speed], + wt=[round(v * 10) for v in settling_time], + mv=[round(v * 10) for v in mix_volume], + mc=mix_cycles, + mp=[round(v * 10) for v in mix_position_in_z_direction_from_liquid_surface], + ms=[round(v * 10) for v in mix_speed], + mh=[round(v * 10) for v in surface_following_distance_during_mixing], + la=TODO_DA_5 if TODO_DA_5 is not None else [0] * len(type_of_aspiration), + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -841,55 +1025,170 @@ async def dispense( mth = backend_params.minimal_traverse_height_at_begin_of_command mhe = backend_params.minimal_height_at_command_end + # Flatten all dispense parameters into local names for guards + send_command. + minimum_height = list(backend_params.minimum_height or well_bottoms) + pull_out_distance_to_take_transport_air_in_function_without_lld = list( + backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld + or [5.0] * len(ops) + ) + immersion_depth = list(backend_params.immersion_depth or [0] * len(ops)) + surface_following_distance = list(backend_params.surface_following_distance or [2.1] * len(ops)) + tube_2nd_section_height_measured_from_zm = list( + backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ) + tube_2nd_section_ratio = list(backend_params.tube_2nd_section_ratio or [0] * len(ops)) + minimal_traverse_height_at_begin_of_command = mth if mth is not None else [th] * len(ops) + minimal_height_at_command_end = mhe if mhe is not None else [th] * len(ops) + cut_off_speed = list(backend_params.cut_off_speed or [250] * len(ops)) + stop_back_volume = list(backend_params.stop_back_volume or [0] * len(ops)) + transport_air_volume = list( + backend_params.transport_air_volume + or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ) + lld_mode = backend_params.lld_mode or [0] * len(ops) + side_touch_off_distance = backend_params.side_touch_off_distance + dispense_position_above_z_touch_off = list( + backend_params.dispense_position_above_z_touch_off or [0.5] * len(ops) + ) + lld_sensitivity = backend_params.lld_sensitivity or [1] * len(ops) + pressure_lld_sensitivity = backend_params.pressure_lld_sensitivity or [1] * len(ops) + swap_speed = list(backend_params.swap_speed or [1] * len(ops)) + settling_time = list(backend_params.settling_time or [0] * len(ops)) + mix_volume = [op.mix.volume if op.mix is not None else 0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_in_z_direction_from_liquid_surface = ( + list(backend_params.mix_position_in_z_direction_from_liquid_surface) + if backend_params.mix_position_in_z_direction_from_liquid_surface is not None + else [0] * len(ops) + ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 1 for op in ops] + surface_following_distance_during_mixing = ( + list(backend_params.surface_following_distance_during_mixing) + if backend_params.surface_following_distance_during_mixing is not None + else [0] * len(ops) + ) + TODO_DD_2 = backend_params.TODO_DD_2 + tadm_algorithm_on_off = backend_params.tadm_algorithm_on_off + limit_curve_index = backend_params.limit_curve_index or [0] * len(ops) + recording_mode = backend_params.recording_mode + + if not all(0 <= x <= 4 for x in type_of_dispensing_mode): + raise ValueError("type_of_dispensing_mode must be in range 0 to 4") + if not all(0 <= x <= 1 for x in channels_involved): + raise ValueError("tip_pattern must be in range 0 to 1") + if not all(0 <= x <= 50000 for x in x_positions): + raise ValueError("x_position must be in range 0 to 50000") + if not all(0 <= x <= 6500 for x in y_positions): + raise ValueError("y_position must be in range 0 to 6500") + if not all(0 <= x <= 360.0 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in lld_search_heights): + raise ValueError("lld_search_height must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in liquid_surfaces_no_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 360.0") + if not all( + 0 <= x <= 360.0 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 360.0" + ) + if not all(-360.0 <= x <= 360.0 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -360.0 to 360.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 360.0") + if not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 1250.0 for x in volumes): + raise ValueError("dispense_volume must be in range 0 to 1250.0") + if not all(1.0 <= x <= 1000.0 for x in flow_rates): + raise ValueError("dispense_speed must be in range 1.0 to 1000.0") + if not all(1.0 <= x <= 1000.0 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 18.0 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 18.0") + if not all(0 <= x <= 50.0 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 50.0") + if not all(0 <= x <= 1250.0 for x in blow_out_air_volumes): + raise ValueError("blow_out_air_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + if not 0 <= side_touch_off_distance <= 4.5: + raise ValueError("side_touch_off_distance must be in range 0 to 4.5") + if not all(0 <= x <= 10.0 for x in dispense_position_above_z_touch_off): + raise ValueError("dispense_position_above_z_touch_off must be in range 0 to 10.0") + if not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + if not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + if not all(0.3 <= x <= 160.0 for x in swap_speed): + raise ValueError("swap_speed must be in range 0.3 to 160.0") + if not all(0 <= x <= 9.9 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 9.9") + if not all(0 <= x <= 1250.0 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + if not all(0 <= x <= 90.0 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 90.0") + if not all(1.0 <= x <= 1000.0 for x in mix_speed): + raise ValueError("mix_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 360.0") + if TODO_DD_2 is not None and not all(0 <= x <= 1 for x in TODO_DD_2): + raise ValueError("TODO_DD_2 must be in range 0 to 1") + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + if not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + try: - await self._pip_dispense( - x_position=x_positions, - y_position=y_positions, - tip_pattern=channels_involved, - type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=list(backend_params.minimum_height or well_bottoms), - lld_search_height=lld_search_heights, - liquid_surface_at_function_without_lld=liquid_surfaces_no_lld, - pull_out_distance_to_take_transport_air_in_function_without_lld=list( - backend_params.pull_out_distance_to_take_transport_air_in_function_without_lld - or [5.0] * len(ops) - ), - immersion_depth=list(backend_params.immersion_depth or [0] * len(ops)), - surface_following_distance=list( - backend_params.surface_following_distance or [2.1] * len(ops) - ), - tube_2nd_section_height_measured_from_zm=list( - backend_params.tube_2nd_section_height_measured_from_zm or [0] * len(ops) - ), - tube_2nd_section_ratio=list(backend_params.tube_2nd_section_ratio or [0] * len(ops)), - minimal_traverse_height_at_begin_of_command=mth if mth is not None else [th] * len(ops), - minimal_height_at_command_end=mhe if mhe is not None else [th] * len(ops), - dispense_volume=volumes, - dispense_speed=flow_rates, - cut_off_speed=list(backend_params.cut_off_speed or [250] * len(ops)), - stop_back_volume=list(backend_params.stop_back_volume or [0] * len(ops)), - transport_air_volume=list( - backend_params.transport_air_volume - or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] - ), - blow_out_air_volume=blow_out_air_volumes, - lld_mode=backend_params.lld_mode or [0] * len(ops), - side_touch_off_distance=backend_params.side_touch_off_distance, - dispense_position_above_z_touch_off=list( - backend_params.dispense_position_above_z_touch_off or [0.5] * len(ops) - ), - lld_sensitivity=backend_params.lld_sensitivity or [1] * len(ops), - pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [1] * len(ops), - swap_speed=list(backend_params.swap_speed or [1] * len(ops)), - settling_time=list(backend_params.settling_time or [0] * len(ops)), - mix_volume=[op.mix.volume if op.mix is not None else 0 for op in ops], - mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], - mix_position_in_z_direction_from_liquid_surface=[0] * len(ops), - mix_speed=[op.mix.flow_rate if op.mix is not None else 1 for op in ops], - surface_following_distance_during_mixing=[0] * len(ops), - tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off, - limit_curve_index=backend_params.limit_curve_index or [0] * len(ops), - recording_mode=backend_params.recording_mode, + await self.driver.send_command( + module="A1PM", + command="DD", + dm=type_of_dispensing_mode, + tm=channels_involved, + xp=x_positions, + yp=y_positions, + zx=[round(v * 10) for v in minimum_height], + lp=[round(v * 10) for v in lld_search_heights], + zl=[round(v * 10) for v in liquid_surfaces_no_lld], + po=[round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld], + ip=[round(v * 10) for v in immersion_depth], + fp=[round(v * 10) for v in surface_following_distance], + zu=[round(v * 10) for v in tube_2nd_section_height_measured_from_zm], + zr=[round(v) for v in tube_2nd_section_ratio], + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + te=[round(v * 10) for v in minimal_height_at_command_end], + dv=[f"{round(v * 100):04}" for v in volumes], + ds=[round(v * 10) for v in flow_rates], + ss=[round(v * 10) for v in cut_off_speed], + rv=[round(v * 10) for v in stop_back_volume], + ta=[round(v * 10) for v in transport_air_volume], + ba=[round(v * 100) for v in blow_out_air_volumes], + lm=lld_mode, + dj=round(side_touch_off_distance * 10), + zo=[round(v * 10) for v in dispense_position_above_z_touch_off], + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=[round(v * 10) for v in swap_speed], + wt=[round(v * 10) for v in settling_time], + mv=[round(v * 10) for v in mix_volume], + mc=mix_cycles, + mp=[round(v * 10) for v in mix_position_in_z_direction_from_liquid_surface], + ms=[round(v * 10) for v in mix_speed], + mh=[round(v * 10) for v in surface_following_distance_during_mixing], + la=TODO_DD_2 if TODO_DD_2 is not None else [0] * len(type_of_dispensing_mode), + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, ) except VantageFirmwareError as e: plr_error = convert_vantage_firmware_error_to_plr_error(e) @@ -908,383 +1207,6 @@ async def request_tip_presence(self) -> List[Optional[bool]]: # -- firmware commands (A1PM) ---------------------------------------------- - async def _pip_tip_pick_up( - self, - x_position: List[int], - y_position: List[int], - tip_pattern: List[bool], - tip_type: List[int], - begin_z_deposit_position: List[float], - end_z_deposit_position: List[float], - minimal_traverse_height_at_begin_of_command: List[float], - minimal_height_at_command_end: List[float], - tip_handling_method: List[int], - blow_out_air_volume: List[float], - ): - """Tip pick up (A1PM:TP). - - Args: - x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). - y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). - begin_z_deposit_position: Begin Z deposit position in mm. - end_z_deposit_position: End Z deposit position in mm. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command in mm. - minimal_height_at_command_end: Minimal height at command end in mm. - blow_out_air_volume: Blow out air volume in uL. - """ - # Convert from PLR standard units to firmware units right before send_command. - fw_begin_z = [round(z * 10) for z in begin_z_deposit_position] - fw_end_z = [round(z * 10) for z in end_z_deposit_position] - fw_th = [round(h * 10) for h in minimal_traverse_height_at_begin_of_command] - fw_te = [round(h * 10) for h in minimal_height_at_command_end] - fw_ba = [round(v * 100) for v in blow_out_air_volume] - - await self.driver.send_command( - module="A1PM", - command="TP", - xp=x_position, - yp=y_position, - tm=tip_pattern, - tt=tip_type, - tp=fw_begin_z, - tz=fw_end_z, - th=fw_th, - te=fw_te, - ba=fw_ba, - td=tip_handling_method, - ) - - async def _pip_tip_discard( - self, - x_position: List[int], - y_position: List[int], - tip_pattern: List[bool], - begin_z_deposit_position: List[float], - end_z_deposit_position: List[float], - minimal_traverse_height_at_begin_of_command: List[float], - minimal_height_at_command_end: List[float], - tip_handling_method: List[int], - TODO_TR_2: int = 0, - ): - """Tip discard (A1PM:TR). - - Args: - x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). - y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). - begin_z_deposit_position: Begin Z deposit position in mm. - end_z_deposit_position: End Z deposit position in mm. - minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command in mm. - minimal_height_at_command_end: Minimal height at command end in mm. - TODO_TR_2: Unknown firmware parameter (maps to firmware key ``ts``). - """ - # Convert from PLR standard units to firmware units right before send_command. - fw_begin_z = [round(z * 10) for z in begin_z_deposit_position] - fw_end_z = [round(z * 10) for z in end_z_deposit_position] - fw_th = [round(h * 10) for h in minimal_traverse_height_at_begin_of_command] - fw_te = [round(h * 10) for h in minimal_height_at_command_end] - - await self.driver.send_command( - module="A1PM", - command="TR", - xp=x_position, - yp=y_position, - tp=fw_begin_z, - tz=fw_end_z, - th=fw_th, - te=fw_te, - tm=tip_pattern, - ts=TODO_TR_2, - td=tip_handling_method, - ) - - async def _pip_aspirate( - self, - x_position: List[int], - y_position: List[int], - type_of_aspiration: List[int], - tip_pattern: List[bool], - minimal_traverse_height_at_begin_of_command: List[float], - minimal_height_at_command_end: List[float], - lld_search_height: List[float], - clot_detection_height: List[float], - liquid_surface_at_function_without_lld: List[float], - pull_out_distance_to_take_transport_air_in_function_without_lld: List[float], - tube_2nd_section_height_measured_from_zm: List[float], - tube_2nd_section_ratio: List[float], - minimum_height: List[float], - immersion_depth: List[float], - surface_following_distance: List[float], - aspiration_volume: List[float], - aspiration_speed: List[float], - transport_air_volume: List[float], - blow_out_air_volume: List[float], - pre_wetting_volume: List[float], - lld_mode: List[int], - lld_sensitivity: List[int], - pressure_lld_sensitivity: List[int], - aspirate_position_above_z_touch_off: List[float], - swap_speed: List[float], - settling_time: List[float], - mix_volume: List[float], - mix_cycles: List[int], - mix_position_in_z_direction_from_liquid_surface: List[int], - mix_speed: List[float], - surface_following_distance_during_mixing: List[int], - capacitive_mad_supervision_on_off: List[int], - pressure_mad_supervision_on_off: List[int], - tadm_algorithm_on_off: int = 0, - limit_curve_index: Optional[List[int]] = None, - recording_mode: int = 0, - TODO_DA_5: Optional[List[int]] = None, - ): - """Aspiration of liquid (A1PM:DA). - - All distances are in mm, volumes in uL, speeds in uL/s, times in seconds. - Conversion to firmware units (0.1mm, 0.01uL, 0.1uL/s, 0.1s) happens internally. - - Args: - x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). - y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). - minimal_traverse_height_at_begin_of_command: mm. - minimal_height_at_command_end: mm. - lld_search_height: mm. - clot_detection_height: mm. - liquid_surface_at_function_without_lld: mm. - pull_out_distance_to_take_transport_air_in_function_without_lld: mm. - tube_2nd_section_height_measured_from_zm: mm. - tube_2nd_section_ratio: ratio (multiplied by 10 for firmware). - minimum_height: mm. - immersion_depth: mm. - surface_following_distance: mm. - aspiration_volume: uL. - aspiration_speed: uL/s. - transport_air_volume: uL. - blow_out_air_volume: uL. - pre_wetting_volume: uL. - aspirate_position_above_z_touch_off: mm. - swap_speed: mm/s. - settling_time: seconds. - mix_volume: uL. - mix_speed: uL/s. - TODO_DA_5: Unknown firmware parameter (maps to firmware key ``la``). Defaults to all zeros. - """ - # Convert from PLR standard units to firmware units right before send_command. - # Distances: mm -> 0.1mm (x10) - fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] - fw_te = [round(v * 10) for v in minimal_height_at_command_end] - fw_lp = [round(v * 10) for v in lld_search_height] - fw_ch = [round(v * 10) for v in clot_detection_height] - fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] - fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] - fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] - fw_zx = [round(v * 10) for v in minimum_height] - fw_ip = [round(v * 10) for v in immersion_depth] - fw_fp = [round(v * 10) for v in surface_following_distance] - fw_zo = [round(v * 10) for v in aspirate_position_above_z_touch_off] - # tube_2nd_section_ratio: dimensionless, already in firmware units - fw_zr = [round(v) for v in tube_2nd_section_ratio] - # Volumes: uL -> 0.01uL (x100) for aspiration_volume and blow_out_air_volume - fw_av = [round(v * 100) for v in aspiration_volume] - fw_ba = [round(v * 100) for v in blow_out_air_volume] - # Volumes: uL -> 0.1uL (x10) for pre_wetting_volume and mix_volume - fw_oa = [round(v * 10) for v in pre_wetting_volume] - fw_mv = [round(v * 10) for v in mix_volume] - # Transport air volume: uL -> 0.1uL (x10) - fw_ta = [round(v * 10) for v in transport_air_volume] - # Speeds: uL/s -> 0.1uL/s (x10) - fw_as = [round(v * 10) for v in aspiration_speed] - fw_ms = [round(v * 10) for v in mix_speed] - # swap_speed: mm/s -> 0.1mm/s (x10) - fw_de = [round(v * 10) for v in swap_speed] - # settling_time: s -> 0.1s (x10) - fw_wt = [round(v * 10) for v in settling_time] - - await self.driver.send_command( - module="A1PM", - command="DA", - at=type_of_aspiration, - tm=tip_pattern, - xp=x_position, - yp=y_position, - th=fw_th, - te=fw_te, - lp=fw_lp, - ch=fw_ch, - zl=fw_zl, - po=fw_po, - zu=fw_zu, - zr=fw_zr, - zx=fw_zx, - ip=fw_ip, - fp=fw_fp, - av=fw_av, - as_=fw_as, - ta=fw_ta, - ba=fw_ba, - oa=fw_oa, - lm=lld_mode, - ll=lld_sensitivity, - lv=pressure_lld_sensitivity, - zo=fw_zo, - de=fw_de, - wt=fw_wt, - mv=fw_mv, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - ms=fw_ms, - mh=surface_following_distance_during_mixing, - la=TODO_DA_5 if TODO_DA_5 is not None else [0] * len(type_of_aspiration), - lb=capacitive_mad_supervision_on_off, - lc=pressure_mad_supervision_on_off, - gj=tadm_algorithm_on_off, - gi=limit_curve_index or [0] * len(type_of_aspiration), - gk=recording_mode, - ) - - async def _pip_dispense( - self, - x_position: List[int], - y_position: List[int], - tip_pattern: List[bool], - type_of_dispensing_mode: List[int], - minimum_height: List[float], - lld_search_height: List[float], - liquid_surface_at_function_without_lld: List[float], - pull_out_distance_to_take_transport_air_in_function_without_lld: List[float], - immersion_depth: List[float], - surface_following_distance: List[float], - tube_2nd_section_height_measured_from_zm: List[float], - tube_2nd_section_ratio: List[float], - minimal_traverse_height_at_begin_of_command: List[float], - minimal_height_at_command_end: List[float], - dispense_volume: List[float], - dispense_speed: List[float], - cut_off_speed: List[float], - stop_back_volume: List[float], - transport_air_volume: List[float], - blow_out_air_volume: List[float], - lld_mode: List[int], - side_touch_off_distance: float, - dispense_position_above_z_touch_off: List[float], - lld_sensitivity: List[int], - pressure_lld_sensitivity: List[int], - swap_speed: List[float], - settling_time: List[float], - mix_volume: List[float], - mix_cycles: List[int], - mix_position_in_z_direction_from_liquid_surface: List[int], - mix_speed: List[float], - surface_following_distance_during_mixing: List[int], - tadm_algorithm_on_off: int = 0, - limit_curve_index: Optional[List[int]] = None, - recording_mode: int = 0, - TODO_DD_2: Optional[List[int]] = None, - ): - """Dispensing of liquid (A1PM:DD). - - All distances are in mm, volumes in uL, speeds in uL/s, times in seconds. - Conversion to firmware units (0.1mm, 0.01uL, 0.1uL/s, 0.1s) happens internally. - - Args: - x_position: X positions in 0.1mm (firmware units, from _ops_to_fw_positions). - y_position: Y positions in 0.1mm (firmware units, from _ops_to_fw_positions). - minimum_height: mm. - lld_search_height: mm. - liquid_surface_at_function_without_lld: mm. - pull_out_distance_to_take_transport_air_in_function_without_lld: mm. - immersion_depth: mm. - surface_following_distance: mm. - tube_2nd_section_height_measured_from_zm: mm. - tube_2nd_section_ratio: ratio (multiplied by 10 for firmware). - minimal_traverse_height_at_begin_of_command: mm. - minimal_height_at_command_end: mm. - dispense_volume: uL. - dispense_speed: uL/s. - cut_off_speed: uL/s. - stop_back_volume: uL. - transport_air_volume: uL. - blow_out_air_volume: uL. - side_touch_off_distance: mm. - dispense_position_above_z_touch_off: mm. - swap_speed: mm/s. - settling_time: seconds. - mix_volume: uL. - mix_speed: uL/s. - TODO_DD_2: Unknown firmware parameter (maps to firmware key ``la``). Defaults to all zeros. - """ - # Convert from PLR standard units to firmware units right before send_command. - # Distances: mm -> 0.1mm (x10) - fw_zx = [round(v * 10) for v in minimum_height] - fw_lp = [round(v * 10) for v in lld_search_height] - fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] - fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] - fw_ip = [round(v * 10) for v in immersion_depth] - fw_fp = [round(v * 10) for v in surface_following_distance] - fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] - fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] - fw_te = [round(v * 10) for v in minimal_height_at_command_end] - fw_zo = [round(v * 10) for v in dispense_position_above_z_touch_off] - fw_dj = round(side_touch_off_distance * 10) - # tube_2nd_section_ratio: dimensionless, already in firmware units - fw_zr = [round(v) for v in tube_2nd_section_ratio] - # Volumes: uL -> 0.01uL (x100) for dispense_volume and blow_out_air_volume - fw_dv = [round(v * 100) for v in dispense_volume] - fw_ba = [round(v * 100) for v in blow_out_air_volume] - # Volumes: uL -> 0.1uL (x10) for stop_back_volume and mix_volume - fw_rv = [round(v * 10) for v in stop_back_volume] - fw_mv = [round(v * 10) for v in mix_volume] - # Transport air volume: uL -> 0.1uL (x10) - fw_ta = [round(v * 10) for v in transport_air_volume] - # Speeds: uL/s -> 0.1uL/s (x10) - fw_ds = [round(v * 10) for v in dispense_speed] - fw_ss = [round(v * 10) for v in cut_off_speed] - fw_ms = [round(v * 10) for v in mix_speed] - # swap_speed: mm/s -> 0.1mm/s (x10) - fw_de = [round(v * 10) for v in swap_speed] - # settling_time: s -> 0.1s (x10) - fw_wt = [round(v * 10) for v in settling_time] - - await self.driver.send_command( - module="A1PM", - command="DD", - dm=type_of_dispensing_mode, - tm=tip_pattern, - xp=x_position, - yp=y_position, - zx=fw_zx, - lp=fw_lp, - zl=fw_zl, - po=fw_po, - ip=fw_ip, - fp=fw_fp, - zu=fw_zu, - zr=fw_zr, - th=fw_th, - te=fw_te, - dv=[f"{vol:04}" for vol in fw_dv], - ds=fw_ds, - ss=fw_ss, - rv=fw_rv, - ta=fw_ta, - ba=fw_ba, - lm=lld_mode, - dj=fw_dj, - zo=fw_zo, - ll=lld_sensitivity, - lv=pressure_lld_sensitivity, - de=fw_de, - wt=fw_wt, - mv=fw_mv, - mc=mix_cycles, - mp=mix_position_in_z_direction_from_liquid_surface, - ms=fw_ms, - mh=surface_following_distance_during_mixing, - la=TODO_DD_2 if TODO_DD_2 is not None else [0] * len(type_of_dispensing_mode), - gj=tadm_algorithm_on_off, - gi=limit_curve_index or [0] * len(type_of_dispensing_mode), - gk=recording_mode, - ) - # -- positioning / query commands (A1PM) ----------------------------------- async def search_for_teach_in_signal_in_x_direction( @@ -1765,43 +1687,98 @@ async def simultaneous_aspiration_dispensation_of_liquid( if limit_curve_index is None: limit_curve_index = [0] * n - # Convert from PLR standard units to firmware units. - # Distances: mm -> 0.1mm (x10) - fw_th = [round(v * 10) for v in minimal_traverse_height_at_begin_of_command] - fw_te = [round(v * 10) for v in minimal_height_at_command_end] - fw_lp = [round(v * 10) for v in lld_search_height] - fw_ch = [round(v * 10) for v in clot_detection_height] - fw_zl = [round(v * 10) for v in liquid_surface_at_function_without_lld] - fw_po = [round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld] - fw_zx = [round(v * 10) for v in minimum_height] - fw_ip = [round(v * 10) for v in immersion_depth] - fw_fp = [round(v * 10) for v in surface_following_distance] - fw_zu = [round(v * 10) for v in tube_2nd_section_height_measured_from_zm] - fw_zo = [round(v * 10) for v in aspirate_position_above_z_touch_off] - fw_mp = [round(v * 10) for v in mix_position_in_z_direction_from_liquid_surface] - fw_mh = [round(v * 10) for v in surface_following_distance_during_mixing] - # Volumes: uL -> 0.01uL (x100) - fw_av = [round(v * 100) for v in aspiration_volume] - fw_ar = [round(v * 100) for v in TODO_DM_3] - fw_dv = [round(v * 100) for v in dispense_volume] - fw_ba = [round(v * 100) for v in blow_out_air_volume] - # Speeds: uL/s -> 0.1uL/s (x10) - fw_as = [round(v * 10) for v in aspiration_speed] - fw_ds = [round(v * 10) for v in dispense_speed] - fw_ss = [round(v * 10) for v in cut_off_speed] - fw_ms = [round(v * 10) for v in mix_speed] - # stop_back_volume: uL -> 0.1uL (x10) - fw_rv = [round(v * 10) for v in stop_back_volume] - # transport_air_volume: uL -> 0.1uL (x10) - fw_ta = [round(v * 10) for v in transport_air_volume] - # pre_wetting_volume: uL -> 0.1uL (x10) - fw_oa = [round(v * 10) for v in pre_wetting_volume] - # mix_volume: uL -> 0.1uL (x10) - fw_mv = [round(v * 10) for v in mix_volume] - # swap_speed: mm/s -> 0.1mm/s (x10) - fw_de = [round(v * 10) for v in swap_speed] - # settling_time: s -> 0.1s (x10) - fw_wt = [round(v * 10) for v in settling_time] + if not all(0 <= x <= 2 for x in type_of_aspiration): + raise ValueError("type_of_aspiration must be in range 0 to 2") + if not all(0 <= x <= 4 for x in type_of_dispensing_mode): + raise ValueError("type_of_dispensing_mode must be in range 0 to 4") + if not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + if not all(0 <= x <= 1 for x in TODO_DM_1): + raise ValueError("TODO_DM_1 must be in range 0 to 1") + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + if not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in lld_search_height): + raise ValueError("lld_search_height must be in range 0 to 360.0") + if not all(0 <= x <= 50.0 for x in clot_detection_height): + raise ValueError("clot_detection_height must be in range 0 to 50.0") + if not all(0 <= x <= 360.0 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 360.0") + if not all( + 0 <= x <= 360.0 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be in range 0 to 360.0" + ) + if not all(0 <= x <= 360.0 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 360.0") + if not all(-360.0 <= x <= 360.0 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -360.0 to 360.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 360.0") + if not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + if not all(0 <= x <= 1250.0 for x in aspiration_volume): + raise ValueError("aspiration_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 1250.0 for x in TODO_DM_3): + raise ValueError("TODO_DM_3 must be in range 0 to 1250.0") + if not all(1.0 <= x <= 1000.0 for x in aspiration_speed): + raise ValueError("aspiration_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 1250.0 for x in dispense_volume): + raise ValueError("dispense_volume must be in range 0 to 1250.0") + if not all(1.0 <= x <= 1000.0 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 1.0 to 1000.0") + if not all(1.0 <= x <= 1000.0 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 18.0 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 18.0") + if not all(0 <= x <= 50.0 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 50.0") + if not all(0 <= x <= 1250.0 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 99.9 for x in pre_wetting_volume): + raise ValueError("pre_wetting_volume must be in range 0 to 99.9") + if not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + if not all(0 <= x <= 10.0 for x in aspirate_position_above_z_touch_off): + raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 10.0") + if not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + if not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + if not all(0.3 <= x <= 160.0 for x in swap_speed): + raise ValueError("swap_speed must be in range 0.3 to 160.0") + if not all(0 <= x <= 9.9 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 9.9") + if not all(0 <= x <= 1250.0 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 1250.0") + if not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + if not all(0 <= x <= 90.0 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 90.0") + if not all(1.0 <= x <= 1000.0 for x in mix_speed): + raise ValueError("mix_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 360.0 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 360.0") + if not all(0 <= x <= 1 for x in TODO_DM_5): + raise ValueError("TODO_DM_5 must be in range 0 to 1") + if not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): + raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") + if not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): + raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + if not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") return await self.driver.send_command( module="A1PM", @@ -1812,38 +1789,38 @@ async def simultaneous_aspiration_dispensation_of_liquid( dd=TODO_DM_1, xp=x_position, yp=y_position, - th=fw_th, - te=fw_te, - lp=fw_lp, - ch=fw_ch, - zl=fw_zl, - po=fw_po, - zx=fw_zx, - ip=fw_ip, - fp=fw_fp, - zu=fw_zu, + th=[round(v * 10) for v in minimal_traverse_height_at_begin_of_command], + te=[round(v * 10) for v in minimal_height_at_command_end], + lp=[round(v * 10) for v in lld_search_height], + ch=[round(v * 10) for v in clot_detection_height], + zl=[round(v * 10) for v in liquid_surface_at_function_without_lld], + po=[round(v * 10) for v in pull_out_distance_to_take_transport_air_in_function_without_lld], + zx=[round(v * 10) for v in minimum_height], + ip=[round(v * 10) for v in immersion_depth], + fp=[round(v * 10) for v in surface_following_distance], + zu=[round(v * 10) for v in tube_2nd_section_height_measured_from_zm], zr=tube_2nd_section_ratio, - av=fw_av, - ar=fw_ar, - as_=fw_as, - dv=fw_dv, - ds=fw_ds, - ss=fw_ss, - rv=fw_rv, - ta=fw_ta, - ba=fw_ba, - oa=fw_oa, + av=[round(v * 100) for v in aspiration_volume], + ar=[round(v * 100) for v in TODO_DM_3], + as_=[round(v * 10) for v in aspiration_speed], + dv=[round(v * 100) for v in dispense_volume], + ds=[round(v * 10) for v in dispense_speed], + ss=[round(v * 10) for v in cut_off_speed], + rv=[round(v * 10) for v in stop_back_volume], + ta=[round(v * 10) for v in transport_air_volume], + ba=[round(v * 100) for v in blow_out_air_volume], + oa=[round(v * 10) for v in pre_wetting_volume], lm=lld_mode, - zo=fw_zo, + zo=[round(v * 10) for v in aspirate_position_above_z_touch_off], ll=lld_sensitivity, lv=pressure_lld_sensitivity, - de=fw_de, - wt=fw_wt, - mv=fw_mv, + de=[round(v * 10) for v in swap_speed], + wt=[round(v * 10) for v in settling_time], + mv=[round(v * 10) for v in mix_volume], mc=mix_cycles, - mp=fw_mp, - ms=fw_ms, - mh=fw_mh, + mp=[round(v * 10) for v in mix_position_in_z_direction_from_liquid_surface], + ms=[round(v * 10) for v in mix_speed], + mh=[round(v * 10) for v in surface_following_distance_during_mixing], la=TODO_DM_5, lb=capacitive_mad_supervision_on_off, lc=pressure_mad_supervision_on_off, @@ -1922,6 +1899,45 @@ async def dispense_on_fly( if limit_curve_index is None: limit_curve_index = [0] * n + if not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + if not -5000 <= first_shoot_x_pos <= 5000: + raise ValueError("first_shoot_x_pos must be in range -5000 to 5000") + if not -5000 <= dispense_on_fly_pos_command_end <= 5000: + raise ValueError("dispense_on_fly_pos_command_end must be in range -5000 to 5000") + if not 0 <= x_acceleration_distance_before_first_shoot <= 90: + raise ValueError("x_acceleration_distance_before_first_shoot must be in range 0 to 90") + if not 0.01 <= space_between_shoots <= 25.0: + raise ValueError("space_between_shoots must be in range 0.01 to 25.0") + if not 2.0 <= x_speed <= 2500.0: + raise ValueError("x_speed must be in range 2.0 to 2500.0") + if not 1 <= number_of_shoots <= 48: + raise ValueError("number_of_shoots must be in range 1 to 48") + if not all(0 <= x <= 360.0 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 360.0") + if not all(0 <= x <= 360.0 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 360.0") + if not all(0 <= x <= 650.0 for x in y_position): + raise ValueError("y_position must be in range 0 to 650.0") + if not all(0 <= x <= 360.0 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 360.0") + if not all(0 <= x <= 1250.0 for x in dispense_volume): + raise ValueError("dispense_volume must be in range 0 to 1250.0") + if not all(1.0 <= x <= 1000.0 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 1.0 to 1000.0") + if not all(1.0 <= x <= 1000.0 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 1.0 to 1000.0") + if not all(0 <= x <= 18.0 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 18.0") + if not all(0 <= x <= 50.0 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 50.0") + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + if not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + return await self.driver.send_command( module="A1PM", command="DF", diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py index e07d9c5e2f7..574693b1ad1 100644 --- a/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py +++ b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py @@ -26,7 +26,7 @@ class Vantage(Device): from pylabrobot.resources.hamilton.vantage_decks import VantageDeck from pylabrobot.hamilton.liquid_handlers.vantage import Vantage - deck = VantageDeck() + deck = VantageDeck(size=1.3) vantage = Vantage(deck=deck) await vantage.setup() @@ -59,13 +59,27 @@ def __init__(self, deck: VantageDeck, chatterbox: bool = False): self.head96: Optional[Head96] = None # set in setup() if installed self.ipg: Optional[OrientableArm] = None # set in setup() if installed - async def setup(self): + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): """Initialize the Vantage hardware and wire up capability frontends. Calls :meth:`VantageDriver.setup` to discover and initialize hardware, then creates PIP, Head96, and IPG frontend capabilities as appropriate. + + Args: + skip_loading_cover: If True, skip loading cover initialization. + skip_core96: If True, skip Core 96-head initialization. + skip_ipg: If True, skip IPG (Integrated Plate Gripper) initialization. """ - await self.driver.setup() + await self.driver.setup( + skip_loading_cover=skip_loading_cover, + skip_core96=skip_core96, + skip_ipg=skip_ipg, + ) # PIP is always present. assert self.driver.pip is not None @@ -89,11 +103,11 @@ async def setup(self): async def stop(self): """Stop the Vantage device and tear down all capabilities. - Stops all capability frontends in reverse order, then stops the driver. - Safe to call if setup was never completed (returns immediately). + Tears down each capability frontend that was added during :meth:`setup` + in reverse order, then stops the driver. Runs unconditionally so that a + ``setup()`` that raised partway through still releases the USB connection + and any backends that finished their own ``_on_setup``. """ - if not self._setup_finished: - return for cap in reversed(self._capabilities): await cap._on_stop() await self.driver.stop() @@ -120,6 +134,10 @@ async def core_grippers( front_channel: The front (higher-numbered) PIP channel to mount the gripper on. The back channel is ``front_channel - 1``. Default 7 (channels 6+7). traversal_height: Minimum Z clearance in mm for safe lateral movement. Default 245.0. + + Raises: + NotImplementedError: Not yet ported from legacy Vantage backend — CoRe gripper tool + pickup firmware command has not been reverse-engineered. """ raise NotImplementedError( "CoRe gripper tool pickup on Vantage has not been reverse-engineered yet. " diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index 2b00a220a42..7883c7c870b 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -264,12 +264,31 @@ async def setup( skip_ipg=skip_ipg, ) + # The driver no longer initializes capability-owned backends in its + # ``_subsystems`` loop — that is the ``Vantage`` device's job. Drive their + # lifecycle explicitly here so the legacy wrapper path still initializes + # the hardware. + assert self.driver.pip is not None + await self.driver.pip._on_setup() + if self.driver.head96 is not None: + await self.driver.head96._on_setup() + if self.driver.ipg is not None: + await self.driver.ipg._on_setup() + # Sync legacy state from driver. self.id_ = 0 self._num_channels = self.driver.num_channels self._setup_done = True async def stop(self): + # Tear down capability-owned backends in reverse setup order before the + # driver closes the USB connection and nulls its subsystem attributes. + if self.driver.ipg is not None: + await self.driver.ipg._on_stop() + if self.driver.head96 is not None: + await self.driver.head96._on_stop() + if self.driver.pip is not None: + await self.driver.pip._on_stop() await self.driver.stop() self._setup_done = False @@ -401,8 +420,6 @@ async def aspirate( ) for op in ops ] - # TODO_DA_5, mix_position_in_z_direction_from_liquid_surface, - # surface_following_distance_during_mixing have no BackendParams equivalent; dropped. await self._vantage_pip.aspirate( new_ops, use_channels, @@ -432,6 +449,11 @@ async def aspirate( aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off, swap_speed=swap_speed, settling_time=settling_time, + mix_position_in_z_direction_from_liquid_surface=( + mix_position_in_z_direction_from_liquid_surface + ), + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + TODO_DA_5=TODO_DA_5, capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off, pressure_mad_supervision_on_off=pressure_mad_supervision_on_off, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -502,8 +524,6 @@ async def dispense( ) for op in ops ] - # TODO_DD_2, mix_position_in_z_direction_from_liquid_surface, - # surface_following_distance_during_mixing have no BackendParams equivalent; dropped. await self._vantage_pip.dispense( new_ops, use_channels, @@ -534,6 +554,11 @@ async def dispense( pressure_lld_sensitivity=pressure_lld_sensitivity, swap_speed=swap_speed, settling_time=settling_time, + mix_position_in_z_direction_from_liquid_surface=( + mix_position_in_z_direction_from_liquid_surface + ), + surface_following_distance_during_mixing=surface_following_distance_during_mixing, + TODO_DD_2=TODO_DD_2, tadm_algorithm_on_off=tadm_algorithm_on_off, limit_curve_index=limit_curve_index, recording_mode=recording_mode, @@ -667,6 +692,10 @@ async def aspirate96( lld_sensitivity=lld_sensitivity, swap_speed=swap_speed, settling_time=settling_time, + mix_position_in_z_direction_from_liquid_surface=( + mix_position_in_z_direction_from_liquid_surface + ), + surface_following_distance_during_mixing=surface_following_distance_during_mixing, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -769,6 +798,10 @@ async def dispense96( side_touch_off_distance=side_touch_off_distance, swap_speed=swap_speed, settling_time=settling_time, + mix_position_in_z_direction_from_liquid_surface=( + mix_position_in_z_direction_from_liquid_surface + ), + surface_following_distance_during_mixing=surface_following_distance_during_mixing, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -1020,121 +1053,215 @@ async def pip_aspirate( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Deprecated: use ``VantagePIPBackend._pip_aspirate``.""" + """Deprecated: use VantagePIPBackend.aspirate().""" + n = self.num_channels if type_of_aspiration is None: - type_of_aspiration = [0] * self.num_channels + type_of_aspiration = [0] * n if tip_pattern is None: - tip_pattern = [False] * self.num_channels + tip_pattern = [False] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + minimal_traverse_height_at_begin_of_command = [3600] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels + minimal_height_at_command_end = [3600] * n if lld_search_height is None: - lld_search_height = [0] * self.num_channels + lld_search_height = [0] * n if clot_detection_height is None: - clot_detection_height = [60] * self.num_channels + clot_detection_height = [60] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels + liquid_surface_at_function_without_lld = [3600] * n if pull_out_distance_to_take_transport_air_in_function_without_lld is None: - pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * n if tube_2nd_section_height_measured_from_zm is None: - tube_2nd_section_height_measured_from_zm = [0] * self.num_channels + tube_2nd_section_height_measured_from_zm = [0] * n if tube_2nd_section_ratio is None: - tube_2nd_section_ratio = [0] * self.num_channels + tube_2nd_section_ratio = [0] * n if minimum_height is None: - minimum_height = [3600] * self.num_channels + minimum_height = [3600] * n if immersion_depth is None: - immersion_depth = [0] * self.num_channels + immersion_depth = [0] * n if surface_following_distance is None: - surface_following_distance = [0] * self.num_channels + surface_following_distance = [0] * n if aspiration_volume is None: - aspiration_volume = [0] * self.num_channels + aspiration_volume = [0] * n if aspiration_speed is None: - aspiration_speed = [500] * self.num_channels + aspiration_speed = [500] * n if transport_air_volume is None: - transport_air_volume = [0] * self.num_channels + transport_air_volume = [0] * n if blow_out_air_volume is None: - blow_out_air_volume = [0] * self.num_channels + blow_out_air_volume = [0] * n if pre_wetting_volume is None: - pre_wetting_volume = [0] * self.num_channels + pre_wetting_volume = [0] * n if lld_mode is None: - lld_mode = [1] * self.num_channels + lld_mode = [1] * n if lld_sensitivity is None: - lld_sensitivity = [1] * self.num_channels + lld_sensitivity = [1] * n if pressure_lld_sensitivity is None: - pressure_lld_sensitivity = [1] * self.num_channels + pressure_lld_sensitivity = [1] * n if aspirate_position_above_z_touch_off is None: - aspirate_position_above_z_touch_off = [5] * self.num_channels + aspirate_position_above_z_touch_off = [5] * n if swap_speed is None: - swap_speed = [100] * self.num_channels + swap_speed = [100] * n if settling_time is None: - settling_time = [5] * self.num_channels + settling_time = [5] * n if mix_volume is None: - mix_volume = [0] * self.num_channels + mix_volume = [0] * n if mix_cycles is None: - mix_cycles = [0] * self.num_channels + mix_cycles = [0] * n if mix_position_in_z_direction_from_liquid_surface is None: - mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels + mix_position_in_z_direction_from_liquid_surface = [250] * n if mix_speed is None: - mix_speed = [500] * self.num_channels + mix_speed = [500] * n if surface_following_distance_during_mixing is None: - surface_following_distance_during_mixing = [0] * self.num_channels + surface_following_distance_during_mixing = [0] * n if TODO_DA_5 is None: - TODO_DA_5 = [0] * self.num_channels + TODO_DA_5 = [0] * n if capacitive_mad_supervision_on_off is None: - capacitive_mad_supervision_on_off = [0] * self.num_channels + capacitive_mad_supervision_on_off = [0] * n if pressure_mad_supervision_on_off is None: - pressure_mad_supervision_on_off = [0] * self.num_channels + pressure_mad_supervision_on_off = [0] * n if limit_curve_index is None: - limit_curve_index = [0] * self.num_channels + limit_curve_index = [0] * n - return await self._vantage_pip._pip_aspirate( - x_position=x_position, - y_position=y_position, - type_of_aspiration=type_of_aspiration, - tip_pattern=tip_pattern, - minimal_traverse_height_at_begin_of_command=[ - v / 10 for v in minimal_traverse_height_at_begin_of_command - ], - minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], - lld_search_height=[v / 10 for v in lld_search_height], - clot_detection_height=[v / 10 for v in clot_detection_height], - liquid_surface_at_function_without_lld=[ - v / 10 for v in liquid_surface_at_function_without_lld - ], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - v / 10 for v in pull_out_distance_to_take_transport_air_in_function_without_lld - ], - tube_2nd_section_height_measured_from_zm=[ - v / 10 for v in tube_2nd_section_height_measured_from_zm - ], - tube_2nd_section_ratio=tube_2nd_section_ratio, - minimum_height=[v / 10 for v in minimum_height], - immersion_depth=[v / 10 for v in immersion_depth], - surface_following_distance=[v / 10 for v in surface_following_distance], - aspiration_volume=[v / 100 for v in aspiration_volume], - aspiration_speed=[v / 10 for v in aspiration_speed], - transport_air_volume=[v / 10 for v in transport_air_volume], - blow_out_air_volume=[v / 100 for v in blow_out_air_volume], - pre_wetting_volume=[v / 10 for v in pre_wetting_volume], - lld_mode=lld_mode, - lld_sensitivity=lld_sensitivity, - pressure_lld_sensitivity=pressure_lld_sensitivity, - aspirate_position_above_z_touch_off=[v / 10 for v in aspirate_position_above_z_touch_off], - swap_speed=[v / 10 for v in swap_speed], - settling_time=[v / 10 for v in settling_time], - mix_volume=[v / 10 for v in mix_volume], - mix_cycles=mix_cycles, - mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, - mix_speed=[v / 10 for v in mix_speed], - surface_following_distance_during_mixing=surface_following_distance_during_mixing, - capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off, - pressure_mad_supervision_on_off=pressure_mad_supervision_on_off, - tadm_algorithm_on_off=tadm_algorithm_on_off, - limit_curve_index=limit_curve_index, - recording_mode=recording_mode, - TODO_DA_5=TODO_DA_5, + assert all(0 <= x <= 2 for x in type_of_aspiration), ( + "type_of_aspiration must be between 0 and 2" + ) + assert all(0 <= x <= 50000 for x in x_position), ( + "x_position must be between 0 and 50000" + ) + assert all(0 <= x <= 6500 for x in y_position), ( + "y_position must be between 0 and 6500" + ) + assert all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command), ( + "minimal_traverse_height_at_begin_of_command must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_height_at_command_end), ( + "minimal_height_at_command_end must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in lld_search_height), ( + "lld_search_height must be between 0 and 3600" + ) + assert all(0 <= x <= 500 for x in clot_detection_height), ( + "clot_detection_height must be between 0 and 500" + ) + assert all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld), ( + "liquid_surface_at_function_without_lld must be between 0 and 3600" + ) + assert all( + 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ), "pull_out_distance_to_take_transport_air_in_function_without_lld must be between 0 and 3600" + assert all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm), ( + "tube_2nd_section_height_measured_from_zm must be between 0 and 3600" + ) + assert all(0 <= x <= 10000 for x in tube_2nd_section_ratio), ( + "tube_2nd_section_ratio must be between 0 and 10000" + ) + assert all(0 <= x <= 3600 for x in minimum_height), ( + "minimum_height must be between 0 and 3600" + ) + assert all(-3600 <= x <= 3600 for x in immersion_depth), ( + "immersion_depth must be between -3600 and 3600" + ) + assert all(0 <= x <= 3600 for x in surface_following_distance), ( + "surface_following_distance must be between 0 and 3600" + ) + assert all(0 <= x <= 125000 for x in aspiration_volume), ( + "aspiration_volume must be between 0 and 125000" + ) + assert all(10 <= x <= 10000 for x in aspiration_speed), ( + "aspiration_speed must be between 10 and 10000" + ) + assert all(0 <= x <= 500 for x in transport_air_volume), ( + "transport_air_volume must be between 0 and 500" + ) + assert all(0 <= x <= 125000 for x in blow_out_air_volume), ( + "blow_out_air_volume must be between 0 and 125000" + ) + assert all(0 <= x <= 999 for x in pre_wetting_volume), ( + "pre_wetting_volume must be between 0 and 999" + ) + assert all(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert all(1 <= x <= 4 for x in lld_sensitivity), ( + "lld_sensitivity must be between 1 and 4" + ) + assert all(1 <= x <= 4 for x in pressure_lld_sensitivity), ( + "pressure_lld_sensitivity must be between 1 and 4" + ) + assert all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off), ( + "aspirate_position_above_z_touch_off must be between 0 and 100" + ) + assert all(3 <= x <= 1600 for x in swap_speed), ( + "swap_speed must be between 3 and 1600" + ) + assert all(0 <= x <= 99 for x in settling_time), ( + "settling_time must be between 0 and 99" + ) + assert all(0 <= x <= 125000 for x in mix_volume), ( + "mix_volume must be between 0 and 125000" + ) + assert all(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface), ( + "mix_position_in_z_direction_from_liquid_surface must be between 0 and 900" + ) + assert all(10 <= x <= 10000 for x in mix_speed), ( + "mix_speed must be between 10 and 10000" + ) + assert all(0 <= x <= 3600 for x in surface_following_distance_during_mixing), ( + "surface_following_distance_during_mixing must be between 0 and 3600" + ) + assert all(0 <= x <= 1 for x in TODO_DA_5), "TODO_DA_5 must be between 0 and 1" + assert all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off), ( + "capacitive_mad_supervision_on_off must be between 0 and 1" + ) + assert all(0 <= x <= 1 for x in pressure_mad_supervision_on_off), ( + "pressure_mad_supervision_on_off must be between 0 and 1" + ) + assert 0 <= tadm_algorithm_on_off <= 1, "tadm_algorithm_on_off must be between 0 and 1" + assert all(0 <= x <= 999 for x in limit_curve_index), ( + "limit_curve_index must be between 0 and 999" + ) + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + return await self.send_command( + module="A1PM", + command="DA", + at=type_of_aspiration, + tm=tip_pattern, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + ch=clot_detection_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + zx=minimum_height, + ip=immersion_depth, + fp=surface_following_distance, + av=aspiration_volume, + as_=aspiration_speed, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + zo=aspirate_position_above_z_touch_off, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=TODO_DA_5, + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, ) async def pip_dispense( @@ -1176,116 +1303,207 @@ async def pip_dispense( limit_curve_index: Optional[List[int]] = None, recording_mode: int = 0, ): - """Deprecated: use ``VantagePIPBackend._pip_dispense``.""" + """Deprecated: use VantagePIPBackend.dispense().""" + n = self.num_channels if type_of_dispensing_mode is None: - type_of_dispensing_mode = [0] * self.num_channels + type_of_dispensing_mode = [0] * n if tip_pattern is None: - tip_pattern = [False] * self.num_channels + tip_pattern = [False] * n if minimum_height is None: - minimum_height = [3600] * self.num_channels + minimum_height = [3600] * n if lld_search_height is None: - lld_search_height = [0] * self.num_channels + lld_search_height = [0] * n if liquid_surface_at_function_without_lld is None: - liquid_surface_at_function_without_lld = [3600] * self.num_channels + liquid_surface_at_function_without_lld = [3600] * n if pull_out_distance_to_take_transport_air_in_function_without_lld is None: - pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * n if immersion_depth is None: - immersion_depth = [0] * self.num_channels + immersion_depth = [0] * n if surface_following_distance is None: - surface_following_distance = [0] * self.num_channels + surface_following_distance = [0] * n if tube_2nd_section_height_measured_from_zm is None: - tube_2nd_section_height_measured_from_zm = [0] * self.num_channels + tube_2nd_section_height_measured_from_zm = [0] * n if tube_2nd_section_ratio is None: - tube_2nd_section_ratio = [0] * self.num_channels + tube_2nd_section_ratio = [0] * n if minimal_traverse_height_at_begin_of_command is None: - minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + minimal_traverse_height_at_begin_of_command = [3600] * n if minimal_height_at_command_end is None: - minimal_height_at_command_end = [3600] * self.num_channels + minimal_height_at_command_end = [3600] * n if dispense_volume is None: - dispense_volume = [0] * self.num_channels + dispense_volume = [0] * n if dispense_speed is None: - dispense_speed = [500] * self.num_channels + dispense_speed = [500] * n if cut_off_speed is None: - cut_off_speed = [250] * self.num_channels + cut_off_speed = [250] * n if stop_back_volume is None: - stop_back_volume = [0] * self.num_channels + stop_back_volume = [0] * n if transport_air_volume is None: - transport_air_volume = [0] * self.num_channels + transport_air_volume = [0] * n if blow_out_air_volume is None: - blow_out_air_volume = [0] * self.num_channels + blow_out_air_volume = [0] * n if lld_mode is None: - lld_mode = [1] * self.num_channels + lld_mode = [1] * n if dispense_position_above_z_touch_off is None: - dispense_position_above_z_touch_off = [5] * self.num_channels + dispense_position_above_z_touch_off = [5] * n if lld_sensitivity is None: - lld_sensitivity = [1] * self.num_channels + lld_sensitivity = [1] * n if pressure_lld_sensitivity is None: - pressure_lld_sensitivity = [1] * self.num_channels + pressure_lld_sensitivity = [1] * n if swap_speed is None: - swap_speed = [100] * self.num_channels + swap_speed = [100] * n if settling_time is None: - settling_time = [5] * self.num_channels + settling_time = [5] * n if mix_volume is None: - mix_volume = [0] * self.num_channels + mix_volume = [0] * n if mix_cycles is None: - mix_cycles = [0] * self.num_channels + mix_cycles = [0] * n if mix_position_in_z_direction_from_liquid_surface is None: - mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels + mix_position_in_z_direction_from_liquid_surface = [250] * n if mix_speed is None: - mix_speed = [500] * self.num_channels + mix_speed = [500] * n if surface_following_distance_during_mixing is None: - surface_following_distance_during_mixing = [0] * self.num_channels + surface_following_distance_during_mixing = [0] * n if TODO_DD_2 is None: - TODO_DD_2 = [0] * self.num_channels + TODO_DD_2 = [0] * n if limit_curve_index is None: - limit_curve_index = [0] * self.num_channels + limit_curve_index = [0] * n - return await self._vantage_pip._pip_dispense( - x_position=x_position, - y_position=y_position, - tip_pattern=tip_pattern, - type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=[v / 10 for v in minimum_height], - lld_search_height=[v / 10 for v in lld_search_height], - liquid_surface_at_function_without_lld=[ - v / 10 for v in liquid_surface_at_function_without_lld - ], - pull_out_distance_to_take_transport_air_in_function_without_lld=[ - v / 10 for v in pull_out_distance_to_take_transport_air_in_function_without_lld - ], - immersion_depth=[v / 10 for v in immersion_depth], - surface_following_distance=[v / 10 for v in surface_following_distance], - tube_2nd_section_height_measured_from_zm=[ - v / 10 for v in tube_2nd_section_height_measured_from_zm - ], - tube_2nd_section_ratio=tube_2nd_section_ratio, - minimal_traverse_height_at_begin_of_command=[ - v / 10 for v in minimal_traverse_height_at_begin_of_command - ], - minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], - dispense_volume=[v / 100 for v in dispense_volume], - dispense_speed=[v / 10 for v in dispense_speed], - cut_off_speed=[v / 10 for v in cut_off_speed], - stop_back_volume=[v / 10 for v in stop_back_volume], - transport_air_volume=[v / 10 for v in transport_air_volume], - blow_out_air_volume=[v / 100 for v in blow_out_air_volume], - lld_mode=lld_mode, - side_touch_off_distance=side_touch_off_distance / 10, - dispense_position_above_z_touch_off=[v / 10 for v in dispense_position_above_z_touch_off], - lld_sensitivity=lld_sensitivity, - pressure_lld_sensitivity=pressure_lld_sensitivity, - swap_speed=[v / 10 for v in swap_speed], - settling_time=[v / 10 for v in settling_time], - mix_volume=[v / 10 for v in mix_volume], - mix_cycles=mix_cycles, - mix_position_in_z_direction_from_liquid_surface=mix_position_in_z_direction_from_liquid_surface, - mix_speed=[v / 10 for v in mix_speed], - surface_following_distance_during_mixing=surface_following_distance_during_mixing, - tadm_algorithm_on_off=tadm_algorithm_on_off, - limit_curve_index=limit_curve_index, - recording_mode=recording_mode, - TODO_DD_2=TODO_DD_2, + assert all(0 <= x <= 4 for x in type_of_dispensing_mode), ( + "type_of_dispensing_mode must be between 0 and 4" + ) + assert all(0 <= x <= 50000 for x in x_position), ( + "x_position must be between 0 and 50000" + ) + assert all(0 <= x <= 6500 for x in y_position), ( + "y_position must be between 0 and 6500" + ) + assert all(0 <= x <= 3600 for x in minimum_height), ( + "minimum_height must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in lld_search_height), ( + "lld_search_height must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld), ( + "liquid_surface_at_function_without_lld must be between 0 and 3600" + ) + assert all( + 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ), "pull_out_distance_to_take_transport_air_in_function_without_lld must be between 0 and 3600" + assert all(-3600 <= x <= 3600 for x in immersion_depth), ( + "immersion_depth must be between -3600 and 3600" + ) + assert all(0 <= x <= 3600 for x in surface_following_distance), ( + "surface_following_distance must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm), ( + "tube_2nd_section_height_measured_from_zm must be between 0 and 3600" + ) + assert all(0 <= x <= 10000 for x in tube_2nd_section_ratio), ( + "tube_2nd_section_ratio must be between 0 and 10000" + ) + assert all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command), ( + "minimal_traverse_height_at_begin_of_command must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_height_at_command_end), ( + "minimal_height_at_command_end must be between 0 and 3600" + ) + assert all(0 <= x <= 125000 for x in dispense_volume), ( + "dispense_volume must be between 0 and 125000" + ) + assert all(10 <= x <= 10000 for x in dispense_speed), ( + "dispense_speed must be between 10 and 10000" + ) + assert all(10 <= x <= 10000 for x in cut_off_speed), ( + "cut_off_speed must be between 10 and 10000" + ) + assert all(0 <= x <= 180 for x in stop_back_volume), ( + "stop_back_volume must be between 0 and 180" + ) + assert all(0 <= x <= 500 for x in transport_air_volume), ( + "transport_air_volume must be between 0 and 500" + ) + assert all(0 <= x <= 125000 for x in blow_out_air_volume), ( + "blow_out_air_volume must be between 0 and 125000" + ) + assert all(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert 0 <= side_touch_off_distance <= 45, ( + "side_touch_off_distance must be between 0 and 45" + ) + assert all(0 <= x <= 100 for x in dispense_position_above_z_touch_off), ( + "dispense_position_above_z_touch_off must be between 0 and 100" + ) + assert all(1 <= x <= 4 for x in lld_sensitivity), ( + "lld_sensitivity must be between 1 and 4" + ) + assert all(1 <= x <= 4 for x in pressure_lld_sensitivity), ( + "pressure_lld_sensitivity must be between 1 and 4" + ) + assert all(3 <= x <= 1600 for x in swap_speed), ( + "swap_speed must be between 3 and 1600" + ) + assert all(0 <= x <= 99 for x in settling_time), ( + "settling_time must be between 0 and 99" + ) + assert all(0 <= x <= 125000 for x in mix_volume), ( + "mix_volume must be between 0 and 125000" + ) + assert all(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface), ( + "mix_position_in_z_direction_from_liquid_surface must be between 0 and 900" + ) + assert all(10 <= x <= 10000 for x in mix_speed), ( + "mix_speed must be between 10 and 10000" + ) + assert all(0 <= x <= 3600 for x in surface_following_distance_during_mixing), ( + "surface_following_distance_during_mixing must be between 0 and 3600" + ) + assert all(0 <= x <= 1 for x in TODO_DD_2), "TODO_DD_2 must be between 0 and 1" + assert 0 <= tadm_algorithm_on_off <= 1, "tadm_algorithm_on_off must be between 0 and 1" + assert all(0 <= x <= 999 for x in limit_curve_index), ( + "limit_curve_index must be between 0 and 999" + ) + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + return await self.send_command( + module="A1PM", + command="DD", + dm=type_of_dispensing_mode, + tm=tip_pattern, + xp=x_position, + yp=y_position, + zx=minimum_height, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + ip=immersion_depth, + fp=surface_following_distance, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + dv=dispense_volume, + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + lm=lld_mode, + dj=side_touch_off_distance, + zo=dispense_position_above_z_touch_off, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=TODO_DD_2, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, ) async def simultaneous_aspiration_dispensation_of_liquid( @@ -1711,7 +1929,7 @@ async def pip_tip_pick_up( blow_out_air_volume: Optional[List[int]] = None, tip_handling_method: Optional[List[int]] = None, ): - """Deprecated: use ``VantagePIPBackend._pip_tip_pick_up``.""" + """Deprecated: use VantagePIPBackend.pick_up_tips/drop_tips.""" if tip_pattern is None: tip_pattern = [False] * self.num_channels @@ -1730,19 +1948,42 @@ async def pip_tip_pick_up( if tip_handling_method is None: tip_handling_method = [0] * self.num_channels - return await self._vantage_pip._pip_tip_pick_up( - x_position=x_position, - y_position=y_position, - tip_pattern=tip_pattern, - tip_type=tip_type, - begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position], - end_z_deposit_position=[v / 10 for v in end_z_deposit_position], - minimal_traverse_height_at_begin_of_command=[ - v / 10 for v in minimal_traverse_height_at_begin_of_command - ], - minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], - tip_handling_method=tip_handling_method, - blow_out_air_volume=[v / 100 for v in blow_out_air_volume], + assert all(0 <= x <= 50000 for x in x_position), "x_position must be between 0 and 50000" + assert all(0 <= x <= 6500 for x in y_position), "y_position must be between 0 and 6500" + assert all(0 <= x <= 1 for x in tip_pattern), "tip_pattern must be between 0 and 1" + assert all(0 <= x <= 199 for x in tip_type), "tip_type must be between 0 and 199" + assert all(0 <= x <= 3600 for x in begin_z_deposit_position), ( + "begin_z_deposit_position must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in end_z_deposit_position), ( + "end_z_deposit_position must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command), ( + "minimal_traverse_height_at_begin_of_command must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_height_at_command_end), ( + "minimal_height_at_command_end must be between 0 and 3600" + ) + assert all(0 <= x <= 125000 for x in blow_out_air_volume), ( + "blow_out_air_volume must be between 0 and 125000" + ) + assert all(0 <= x <= 9 for x in tip_handling_method), ( + "tip_handling_method must be between 0 and 9" + ) + + return await self.send_command( + module="A1PM", + command="TP", + xp=x_position, + yp=y_position, + tm=tip_pattern, + tt=tip_type, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ba=blow_out_air_volume, + td=tip_handling_method, ) async def pip_tip_discard( @@ -1757,7 +1998,7 @@ async def pip_tip_discard( TODO_TR_2: int = 0, tip_handling_method: Optional[List[int]] = None, ): - """Deprecated: use ``VantagePIPBackend._pip_tip_discard``.""" + """Deprecated: use VantagePIPBackend.pick_up_tips/drop_tips.""" if begin_z_deposit_position is None: begin_z_deposit_position = [0] * self.num_channels @@ -1772,18 +2013,38 @@ async def pip_tip_discard( if tip_handling_method is None: tip_handling_method = [0] * self.num_channels - return await self._vantage_pip._pip_tip_discard( - x_position=x_position, - y_position=y_position, - tip_pattern=tip_pattern, - begin_z_deposit_position=[v / 10 for v in begin_z_deposit_position], - end_z_deposit_position=[v / 10 for v in end_z_deposit_position], - minimal_traverse_height_at_begin_of_command=[ - v / 10 for v in minimal_traverse_height_at_begin_of_command - ], - minimal_height_at_command_end=[v / 10 for v in minimal_height_at_command_end], - tip_handling_method=tip_handling_method, - TODO_TR_2=TODO_TR_2, + assert all(0 <= x <= 50000 for x in x_position), "x_position must be between 0 and 50000" + assert all(0 <= x <= 6500 for x in y_position), "y_position must be between 0 and 6500" + assert all(0 <= x <= 3600 for x in begin_z_deposit_position), ( + "begin_z_deposit_position must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in end_z_deposit_position), ( + "end_z_deposit_position must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command), ( + "minimal_traverse_height_at_begin_of_command must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in minimal_height_at_command_end), ( + "minimal_height_at_command_end must be between 0 and 3600" + ) + assert all(0 <= x <= 1 for x in tip_pattern), "tip_pattern must be between 0 and 1" + assert -1000 <= TODO_TR_2 <= 1000, "TODO_TR_2 must be between -1000 and 1000" + assert all(0 <= x <= 9 for x in tip_handling_method), ( + "tip_handling_method must be between 0 and 9" + ) + + return await self.send_command( + module="A1PM", + command="TR", + xp=x_position, + yp=y_position, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + tm=tip_pattern, + ts=TODO_TR_2, + td=tip_handling_method, ) async def search_for_teach_in_signal_in_x_direction( From 89b92b2bf1544513f5c4f564fbf9757c0de5b3f4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 16 Apr 2026 16:11:16 -0700 Subject: [PATCH 11/11] Add Vantage user guide and API reference - docs/user_guide/hamilton/vantage/: new section with an index and a hello-world notebook walking through Vantage setup, deck layout, tip handling, aspiration/dispensing, IPG plate transport, and teardown against the chatterbox driver. - docs/user_guide/hamilton/index.md: link the new Vantage section from the Hamilton index. - docs/api/pylabrobot.hamilton.rst: autosummary + autoclass entries for Vantage, VantagePIPBackend, VantageHead96Backend, IPGBackend and their BackendParams (PickUp/DropTips, Aspirate/Dispense, PickUp/Drop). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/pylabrobot.hamilton.rst | 70 ++++ docs/user_guide/hamilton/index.md | 1 + .../hamilton/vantage/hello-world.ipynb | 308 ++++++++++++++++++ docs/user_guide/hamilton/vantage/index.md | 7 + 4 files changed, 386 insertions(+) create mode 100644 docs/user_guide/hamilton/vantage/hello-world.ipynb create mode 100644 docs/user_guide/hamilton/vantage/index.md diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst index 836a2356b5b..a632b63f415 100644 --- a/docs/api/pylabrobot.hamilton.rst +++ b/docs/api/pylabrobot.hamilton.rst @@ -137,3 +137,73 @@ STAR Liquid Handler .. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.MoveToLocationParams :members: + + +Vantage Liquid Handler +---------------------- + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.vantage + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Vantage + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.vantage.pip_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + VantagePIPBackend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.pip_backend.VantagePIPBackend.PickUpTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.pip_backend.VantagePIPBackend.DropTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.pip_backend.VantagePIPBackend.AspirateParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.pip_backend.VantagePIPBackend.DispenseParams + :members: + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.vantage.head96_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + VantageHead96Backend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.head96_backend.VantageHead96Backend.PickUpTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.head96_backend.VantageHead96Backend.DropTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.head96_backend.VantageHead96Backend.AspirateParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.head96_backend.VantageHead96Backend.DispenseParams + :members: + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.vantage.ipg + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + IPGBackend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.ipg.IPGBackend.PickUpParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.vantage.ipg.IPGBackend.DropParams + :members: diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md index a85cdff1ce7..cdfcaaf6a22 100644 --- a/docs/user_guide/hamilton/index.md +++ b/docs/user_guide/hamilton/index.md @@ -4,6 +4,7 @@ :maxdepth: 1 star/index +vantage/index heater_cooler/hello-world heater_shaker/hello-world hepa_fan/hello-world diff --git a/docs/user_guide/hamilton/vantage/hello-world.ipynb b/docs/user_guide/hamilton/vantage/hello-world.ipynb new file mode 100644 index 00000000000..f12c4bbefde --- /dev/null +++ b/docs/user_guide/hamilton/vantage/hello-world.ipynb @@ -0,0 +1,308 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "vantage-intro-001", + "metadata": {}, + "source": [ + "# Hamilton Vantage\n", + "\n", + "The Hamilton Vantage is a liquid handler with independent pipetting channels, an optional 96-head, and an optional Integrated Plate Gripper (IPG). This notebook walks through the `Vantage` device API, covering setup, deck layout, tip pickup, aspiration and dispensing, IPG plate transport, and teardown.\n", + "\n", + "**Prerequisites:**\n", + "\n", + "- Installed PyLabRobot with USB support: `pip install pylabrobot[usb]`\n", + "- Platform-specific driver setup (libusb on Mac, Zadig on Windows) — see [the installation guide](../../_getting-started/installation)\n", + "- Connected the Vantage to your computer using the USB cable\n", + "\n", + "All commands in this notebook are sent to a chatterbox driver that logs firmware strings to the console without touching real hardware. Remove `chatterbox=True` and supply a real USB address to run on an actual instrument." + ] + }, + { + "cell_type": "markdown", + "id": "vantage-setup-heading", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.vantage.vantage.Vantage` and {class}`~pylabrobot.resources.hamilton.vantage_decks.VantageDeck`. `Vantage` is the device class that owns the driver and exposes capabilities: `vantage.pip` (independent channels), `vantage.head96` (96-head, if installed), and `vantage.ipg` (plate gripper arm, if installed).\n", + "\n", + "`VantageDeck` requires a `size` parameter. The standard 1.3 m configuration is used here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.vantage import Vantage\n", + "from pylabrobot.resources.hamilton import VantageDeck\n", + "\n", + "deck = VantageDeck(size=1.3)\n", + "vantage = Vantage(deck=deck, chatterbox=True)" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-setup-call-note", + "metadata": {}, + "source": [ + "Call {meth}`~pylabrobot.hamilton.liquid_handlers.vantage.vantage.Vantage.setup` to initialise the driver and wire up the PIP, Head96, and IPG capability frontends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-setup-run", + "metadata": {}, + "outputs": [], + "source": [ + "await vantage.setup()\n", + "\n", + "print(f\"pip channels : {vantage.pip.num_channels}\")\n", + "print(f\"head96 present: {vantage.head96 is not None}\")\n", + "print(f\"ipg present : {vantage.ipg is not None}\")" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-deck-heading", + "metadata": {}, + "source": [ + "## Creating the deck layout\n", + "\n", + "Define the physical deck layout by assigning carriers with tip racks and plates. This tutorial uses:\n", + "\n", + "- {class}`~pylabrobot.resources.hamilton.tip_carriers.TIP_CAR_480_A00` tip carrier\n", + "- {class}`~pylabrobot.resources.hamilton.plate_carriers.PLT_CAR_L5AC_A00` plate carrier\n", + "- {class}`~pylabrobot.resources.corning.plates.Cor_96_wellplate_360ul_Fb` 96-well plate\n", + "- {class}`~pylabrobot.resources.hamilton.tip_racks.hamilton_96_tiprack_1000uL_filter` tip rack" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-deck-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import (\n", + " Cor_96_wellplate_360ul_Fb,\n", + " PLT_CAR_L5AC_A00,\n", + " TIP_CAR_480_A00,\n", + " hamilton_96_tiprack_1000uL_filter,\n", + ")\n", + "\n", + "tip_car = TIP_CAR_480_A00(name=\"tip_carrier\")\n", + "tip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\n", + "tip_car[1] = hamilton_96_tiprack_1000uL_filter(name=\"tips_02\")\n", + "deck.assign_child_resource(tip_car, rails=3)\n", + "\n", + "plt_car = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", + "plt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\n", + "plt_car[1] = Cor_96_wellplate_360ul_Fb(name=\"plate_02\")\n", + "deck.assign_child_resource(plt_car, rails=15)" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-deck-summary-note", + "metadata": {}, + "source": [ + "Inspect the deck layout using {meth}`~pylabrobot.resources.deck.Deck.summary`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-deck-summary", + "metadata": {}, + "outputs": [], + "source": [ + "deck.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-pip-heading", + "metadata": {}, + "source": [ + "## Picking up tips\n", + "\n", + "`vantage.pip` is a {class}`~pylabrobot.capabilities.liquid_handling.pip.PIP` capability frontend. Its API is identical to `star.pip` on the STAR. Indexing a tip rack with `[\"A1:C1\"]` returns the first three tip spots in column 1." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-pickup-tips", + "metadata": {}, + "outputs": [], + "source": [ + "tiprack = deck.get_resource(\"tips_01\")\n", + "await vantage.pip.pick_up_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-aspirate-heading", + "metadata": {}, + "source": [ + "## Aspirating and dispensing\n", + "\n", + "Aspirate from wells `A1:C1` of the source plate and dispense into `A1:C1` of the destination plate, using channels 0, 1, and 2." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-aspirate", + "metadata": {}, + "outputs": [], + "source": [ + "src = deck.get_resource(\"plate_01\")\n", + "dst = deck.get_resource(\"plate_02\")\n", + "\n", + "await vantage.pip.aspirate(src[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-dispense", + "metadata": {}, + "outputs": [], + "source": [ + "await vantage.pip.dispense(dst[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-move-back-note", + "metadata": {}, + "source": [ + "Move the liquid back to the source plate:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-move-back", + "metadata": {}, + "outputs": [], + "source": [ + "await vantage.pip.aspirate(dst[\"A1:C1\"], vols=[100.0, 50.0, 200.0])\n", + "await vantage.pip.dispense(src[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-drop-tips-heading", + "metadata": {}, + "source": [ + "## Dropping tips\n", + "\n", + "Return tips to their original positions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-drop-tips", + "metadata": {}, + "outputs": [], + "source": [ + "await vantage.pip.drop_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-backend-params-heading", + "metadata": {}, + "source": "## Backend parameters\n\nFor Vantage-specific tuning, pass `backend_params` to any PIP operation. The available parameter dataclasses live on `VantagePIPBackend` in `pylabrobot.hamilton.liquid_handlers.vantage.pip_backend`. For example, to aspirate with capacitive liquid level detection (cLLD, `lld_mode=1`) enabled on two channels:" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-backend-params-code", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend\n\ntiprack2 = deck.get_resource(\"tips_02\")\nawait vantage.pip.pick_up_tips(tiprack2[\"A1:B1\"])\n\nawait vantage.pip.aspirate(\n src[\"A1:B1\"],\n vols=[100.0, 100.0],\n backend_params=VantagePIPBackend.AspirateParams(\n lld_mode=[1, 1], # 1 = cLLD (capacitive liquid level detection)\n ),\n)\n\nawait vantage.pip.dispense(dst[\"A1:B1\"], vols=[100.0, 100.0])\nawait vantage.pip.drop_tips(tiprack2[\"A1:B1\"])" + }, + { + "cell_type": "markdown", + "id": "vantage-ipg-heading", + "metadata": {}, + "source": [ + "## IPG: Integrated Plate Gripper\n", + "\n", + "`vantage.ipg` is an {class}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm` capability frontend backed by {class}`~pylabrobot.hamilton.liquid_handlers.vantage.ipg.IPGBackend`. It is `None` if the IPG is not installed on this configuration. The chatterbox driver always provides one.\n", + "\n", + "Use {meth}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm.move_resource` to pick up a plate from one carrier slot and place it in another. Pass `pickup_backend_params` and `drop_backend_params` to override IPG-specific firmware parameters.\n", + "\n", + "```{note}\n", + "`press_on_distance` on `IPGBackend.DropParams` is accepted for API compatibility but is not forwarded to the firmware: the `zi` parameter on the A1RM:DR command is uncharacterised on real hardware and has been left unsent until it can be verified safe.\n", + "```\n", + "\n", + "```{warning}\n", + "`vantage.ipg.halt()`, `vantage.ipg.close_gripper()`, `vantage.ipg.is_gripper_closed()`, and `vantage.ipg.request_gripper_location()` raise `NotImplementedError` — they have not yet been ported from the legacy backend. Do not rely on `halt()` for emergency stop.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-ipg-code", + "metadata": {}, + "outputs": [], + "source": [ + "if vantage.ipg is not None:\n", + " plate = deck.get_resource(\"plate_01\")\n", + " destination_slot = plt_car[2] # empty slot 2\n", + "\n", + " await vantage.ipg.move_resource(\n", + " resource=plate,\n", + " to=destination_slot,\n", + " pickup_distance_from_top=13.2,\n", + " )\n", + "else:\n", + " print(\"IPG not present on this configuration — skipping.\")" + ] + }, + { + "cell_type": "markdown", + "id": "vantage-teardown-heading", + "metadata": {}, + "source": [ + "## Teardown\n", + "\n", + "Call {meth}`~pylabrobot.hamilton.liquid_handlers.vantage.vantage.Vantage.stop` to stop all capability frontends in reverse order and close the driver connection." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "vantage-stop", + "metadata": {}, + "outputs": [], + "source": [ + "await vantage.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/hamilton/vantage/index.md b/docs/user_guide/hamilton/vantage/index.md new file mode 100644 index 00000000000..0f4d86e8ead --- /dev/null +++ b/docs/user_guide/hamilton/vantage/index.md @@ -0,0 +1,7 @@ +# Hamilton Vantage + +```{toctree} +:maxdepth: 1 + +hello-world +```