diff --git a/pylabrobot/capabilities/led_control/__init__.py b/pylabrobot/capabilities/led_control/__init__.py new file mode 100644 index 00000000000..398bcac1379 --- /dev/null +++ b/pylabrobot/capabilities/led_control/__init__.py @@ -0,0 +1,2 @@ +from .backend import LEDBackend +from .led_control import LEDControlCapability diff --git a/pylabrobot/capabilities/led_control/backend.py b/pylabrobot/capabilities/led_control/backend.py new file mode 100644 index 00000000000..35df17cbbc8 --- /dev/null +++ b/pylabrobot/capabilities/led_control/backend.py @@ -0,0 +1,35 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + + +class LEDBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for LED control.""" + + @abstractmethod + async def set_color( + self, + mode: str, + intensity: int, + white: int, + red: int, + green: int, + blue: int, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set the LED color. + + Args: + mode: "on", "off", or "blink". + intensity: Brightness 0-100. + white: White channel 0-100. + red: Red channel 0-100. + green: Green channel 0-100. + blue: Blue channel 0-100. + backend_params: Vendor-specific parameters (e.g. UV, blink interval). + """ + + @abstractmethod + async def turn_off(self) -> None: + """Turn the LED off.""" diff --git a/pylabrobot/capabilities/led_control/led_control.py b/pylabrobot/capabilities/led_control/led_control.py new file mode 100644 index 00000000000..63a95161fba --- /dev/null +++ b/pylabrobot/capabilities/led_control/led_control.py @@ -0,0 +1,60 @@ +import asyncio +import random +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability + +from .backend import LEDBackend + + +class LEDControlCapability(Capability): + """LED control capability with convenience methods.""" + + def __init__(self, backend: LEDBackend): + super().__init__(backend=backend) + self.backend: LEDBackend = backend + + async def set_color( + self, + mode: str = "on", + intensity: int = 100, + white: int = 0, + red: int = 0, + green: int = 0, + blue: int = 0, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set the LED color. + + Args: + mode: "on", "off", or "blink". + intensity: Brightness 0-100. + white: White channel 0-100. + red: Red channel 0-100. + green: Green channel 0-100. + blue: Blue channel 0-100. + backend_params: Vendor-specific parameters. + """ + await self.backend.set_color( + mode=mode, intensity=intensity, + white=white, red=red, green=green, blue=blue, + backend_params=backend_params, + ) + + async def turn_off(self) -> None: + """Turn the LED off.""" + await self.backend.turn_off() + + async def disco_mode(self, cycles: int = 69, delay: float = 0.1) -> None: + """Cycle through random colors. + + Args: + cycles: Number of color changes. + delay: Seconds between color changes. + """ + for _ in range(cycles): + r = random.randint(30, 100) + g = random.randint(30, 100) + b = random.randint(30, 100) + await self.set_color(mode="on", intensity=100, red=r, green=g, blue=b) + await asyncio.sleep(delay) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py new file mode 100644 index 00000000000..2af59fc48e3 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/__init__.py @@ -0,0 +1,14 @@ +from .chatterbox import VantageChatterboxDriver +from .driver import ( + VantageDriver, + VantageFirmwareError, + parse_vantage_fw_string, + vantage_response_string_to_error, +) +from .head96_backend import VantageHead96Backend +from .ipg import VantageIPG +from .led_backend import VantageLEDBackend, VantageLEDParams +from .loading_cover import VantageLoadingCover +from .pip_backend import VantagePIPBackend +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 new file mode 100644 index 00000000000..a835de4bf95 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/chatterbox.py @@ -0,0 +1,79 @@ +"""VantageChatterboxDriver: prints commands instead of sending them over USB.""" + +from .driver import VantageDriver + + +class VantageChatterboxDriver(VantageDriver): + """Chatterbox driver for Vantage. Prints firmware commands instead of sending them over USB.""" + + def __init__(self, num_channels: int = 8): + super().__init__() + self._num_channels_override = num_channels + + @property + def num_channels(self) -> int: + return self._num_channels_override + + # -- lifecycle: skip USB, use canned config -------------------------------- + + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + # No USB — just set up backends directly. + self.id_ = 0 + self._num_channels = self._num_channels_override + + from .pip_backend import VantagePIPBackend + + self.pip = VantagePIPBackend(self, tip_presences=[False] * self._num_channels_override) + + if not skip_core96: + from .head96_backend import VantageHead96Backend + + self.head96 = VantageHead96Backend(self) + else: + self.head96 = None + + if not skip_ipg: + from .ipg import VantageIPG + + self.ipg = VantageIPG(driver=self) + self.ipg._parked = True + else: + self.ipg = None + + from .led_backend import VantageLEDBackend + from .loading_cover import VantageLoadingCover + from .x_arm import VantageXArm + + self.led = VantageLEDBackend(self) + self.loading_cover = VantageLoadingCover(driver=self) + self.x_arm = VantageXArm(driver=self) + + async def stop(self): + self._num_channels = None + self.head96 = None + self.ipg = None + self.led = None + self.loading_cover = None + self.x_arm = None + + # -- I/O: print instead of USB -------------------------------------------- + + async def send_command(self, module, command, auto_id=True, tip_pattern=None, + write_timeout=None, read_timeout=None, wait=True, + fmt=None, **kwargs): + cmd, _ = self._assemble_command( + module=module, command=command, auto_id=auto_id, + tip_pattern=tip_pattern, **kwargs, + ) + print(cmd) + return None + + async def send_raw_command(self, command, write_timeout=None, read_timeout=None, + wait=True): + print(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..363b9403d91 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/driver.py @@ -0,0 +1,509 @@ +"""VantageDriver: inherits HamiltonLiquidHandler, adds Vantage-specific config and error handling.""" + +import re +from typing import Any, Dict, List, Optional, cast + +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler +from pylabrobot.resources.hamilton import TipPickupMethod, TipSize + + +# --------------------------------------------------------------------------- +# Firmware string parsing +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +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_name = { + "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 = {module_name: error_string} + + return VantageFirmwareError(errors, string) + + +# --------------------------------------------------------------------------- +# VantageDriver +# --------------------------------------------------------------------------- + + +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 module 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: PIPBackend # set in setup() + self.head96: Optional[Head96Backend] = None # set in setup() if installed + self.ipg: Optional["VantageIPG"] = None # set in setup() if installed + + # -- 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("num_channels is not set. 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): + if "er" in resp and "er0" not in resp: + error = vantage_response_string_to_error(resp) + raise error + + def _parse_response(self, resp: str, fmt: Any) -> dict: + return parse_vantage_fw_string(resp, fmt) + + # -- lifecycle ------------------------------------------------------------ + + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + await super().setup() + self.id_ = 0 + + 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() + + # Create backends based on discovered hardware. + from .pip_backend import VantagePIPBackend # deferred to avoid circular imports + + self.pip = VantagePIPBackend(self, tip_presences=tip_presences) + + # TODO: detect core96 installation from hardware rather than skip flag. + if not skip_core96: + from .head96_backend import VantageHead96Backend + + self.head96 = VantageHead96Backend(self) + else: + self.head96 = None + + if not skip_ipg: + from .ipg import VantageIPG + + self.ipg = VantageIPG(driver=self) + else: + self.ipg = None + + # LED backend (always present). + from .led_backend import VantageLEDBackend + + self.led = VantageLEDBackend(self) + + # Create plain subsystems. + from .loading_cover import VantageLoadingCover + from .x_arm import VantageXArm + + self.loading_cover = VantageLoadingCover(driver=self) + self.x_arm = VantageXArm(driver=self) + + 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() + + async def stop(self): + await super().stop() + self._num_channels = None + self.head96 = None + self.ipg = None + self.led = None + self.loading_cover = None + self.x_arm = None + + # -- traversal height ----------------------------------------------------- + + 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" + self._traversal_height = traversal_height + + @property + def traversal_height(self) -> float: + return self._traversal_height + + # -- device-level commands ------------------------------------------------ + + 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, + ): + """Tip/needle definition. + + Args: + tip_type_table_index: tip_table_index + has_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_size: 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 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, + ) + + async def query_tip_presence(self) -> List[bool]: + """Query tip presence on all channels.""" + resp = await self.send_command(module="A1PM", command="QA", fmt={"rt": "[int]"}) + if resp is None: + return [] + presences_int = cast(List[int], resp["rt"]) + return [bool(p) for p in presences_int] + + async def arm_request_instrument_initialization_status(self) -> bool: + """Request the instrument initialization status. + + 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 + + async def arm_pre_initialize(self): + """Initialize the arm module.""" + return await self.send_command(module="A1AM", command="MI") + 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..cdf1ed45cd6 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/head96_backend.py @@ -0,0 +1,1271 @@ +"""Vantage Head96 backend: translates Head96 operations into Vantage firmware commands.""" + +from __future__ import annotations + +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.resources import Coordinate +from pylabrobot.resources.hamilton import HamiltonTip + +if TYPE_CHECKING: + from .driver import VantageDriver + + +def _dispensing_mode_for_op(jet: bool, empty: bool, blow_out: bool) -> int: + """Compute firmware dispensing mode from boolean flags. + + Firmware modes: + 0 = Part in jet + 1 = Blow in jet (called "empty" in VENUS liquid editor) + 2 = Part at surface + 3 = Blow at surface (called "empty" in VENUS liquid editor) + 4 = Empty (truly empty) + """ + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +def _tadm_channel_pattern_to_hex(pattern: List[bool]) -> str: + """Convert a list of 96 booleans to the hex string expected by firmware.""" + assert len(pattern) == 96, "channel_pattern must be a list of 96 boolean values" + pattern_num = sum(2**i if pattern[i] else 0 for i in range(96)) + return hex(pattern_num)[2:].upper() + + +class VantageHead96Backend(Head96Backend): + """Translates Head96 operations into Vantage firmware commands via the driver. + + All protocol encoding (parameter formatting, validation, hex encoding) lives here. + The driver is used only for ``send_command`` I/O. + """ + + def __init__(self, driver: VantageDriver): + self._driver = driver + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + async def _on_setup(self): + """Check Core96 initialization status and initialize if needed.""" + initialized = await self.core96_request_initialization_status() + if not initialized: + traversal = round(self._driver.traversal_height * 10) + 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=traversal, + minimal_height_at_command_end=traversal, + end_z_deposit_position=2420, + ) + + # --------------------------------------------------------------------------- + # Pick up tips (ABC implementation) + # --------------------------------------------------------------------------- + + @dataclass + class PickUpTips96Params(BackendParams): + """Vantage-specific parameters for 96-head tip pickup.""" + + tip_handling_method: int = 0 + z_deposit_position: float = 216.4 + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + minimum_height_at_command_end: Optional[float] = None + + async def pick_up_tips96( + self, pickup: PickupTipRack, backend_params: Optional[BackendParams] = None + ): + """Pick up tips using the 96 head. + + Firmware command: A1HM TP + """ + if not isinstance(backend_params, VantageHead96Backend.PickUpTips96Params): + backend_params = VantageHead96Backend.PickUpTips96Params() + + 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.") + if not isinstance(prototypical_tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + ttti = await self._driver.request_or_assign_tip_type_index(prototypical_tip) + + position = ( + tip_spot_a1.get_absolute_location() + tip_spot_a1.center() + pickup.offset + ) + offset_z = pickup.offset.z + + traversal = self._driver.traversal_height + + 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 + offset_z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimum_height_at_command_end or traversal) * 10 + ), + ) + + # --------------------------------------------------------------------------- + # Drop tips (ABC implementation) + # --------------------------------------------------------------------------- + + @dataclass + class DropTips96Params(BackendParams): + """Vantage-specific parameters for 96-head tip drop.""" + + z_deposit_position: float = 216.4 + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + minimum_height_at_command_end: Optional[float] = None + + async def drop_tips96( + self, drop: DropTipRack, backend_params: Optional[BackendParams] = None + ): + """Drop tips from the 96 head. + + Firmware command: A1HM TR + """ + if not isinstance(backend_params, VantageHead96Backend.DropTips96Params): + backend_params = VantageHead96Backend.DropTips96Params() + + from pylabrobot.resources import TipRack + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item("A1") + position = tip_spot_a1.get_absolute_location() + 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 + traversal = self._driver.traversal_height + + 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 + offset_z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimum_height_at_command_end or traversal) * 10 + ), + ) + + # --------------------------------------------------------------------------- + # Aspirate (ABC implementation) + # --------------------------------------------------------------------------- + + @dataclass + class Aspirate96Params(BackendParams): + """Vantage-specific parameters for 96-head aspiration.""" + + type_of_aspiration: int = 0 + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + minimum_height_at_command_end: Optional[float] = None + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5.0 + 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 + mix_position_in_z_direction_from_liquid_surface: float = 0 + surface_following_distance_during_mixing: float = 0 + limit_curve_index: int = 0 + tadm_channel_pattern: Optional[List[bool]] = None + tadm_algorithm_on_off: int = 0 + recording_mode: int = 0 + + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate using the Core96 head. + + Firmware command: A1HM DA + """ + if not isinstance(backend_params, VantageHead96Backend.Aspirate96Params): + backend_params = VantageHead96Backend.Aspirate96Params() + + # Compute position + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert plate is not None, "MultiHeadAspirationPlate well parent must not be None" + 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 = aspiration.wells[-1] + elif rot.z % 360 == 0: + ref_well = aspiration.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_absolute_location() + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + aspiration.offset + ) + # -1 compared to STAR + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 + else: + # Container (trough): center the head + 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_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() + 2.7 - 1 + + liquid_height = position.z + (aspiration.liquid_height or 0) + + volume = aspiration.volume + flow_rate = aspiration.flow_rate or 250 + transport_air_volume = backend_params.transport_air_volume or 0 + blow_out_air_volume = aspiration.blow_out_air_volume or backend_params.blow_out_air_volume or 0 + swap_speed = backend_params.swap_speed or 100 + settling_time = backend_params.settling_time or 5 + + traversal = self._driver.traversal_height + + await self.core96_aspiration_of_liquid( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + type_of_aspiration=backend_params.type_of_aspiration, + minimal_traverse_height_at_begin_of_command=round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimum_height_at_command_end or traversal) * 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=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=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, + ) + + # --------------------------------------------------------------------------- + # Dispense (ABC implementation) + # --------------------------------------------------------------------------- + + @dataclass + class Dispense96Params(BackendParams): + """Vantage-specific parameters for 96-head dispense.""" + + jet: bool = False + blow_out: bool = False + empty: bool = False + type_of_dispensing_mode: Optional[int] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + minimum_height_at_command_end: Optional[float] = 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 + 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 + mix_position_in_z_direction_from_liquid_surface: float = 0 + surface_following_distance_during_mixing: float = 0 + limit_curve_index: int = 0 + tadm_channel_pattern: Optional[List[bool]] = None + tadm_algorithm_on_off: int = 0 + recording_mode: int = 0 + + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + """Dispense using the Core96 head. + + Firmware command: A1HM DD + """ + if not isinstance(backend_params, VantageHead96Backend.Dispense96Params): + backend_params = VantageHead96Backend.Dispense96Params() + + # Compute position + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert plate is not None, "MultiHeadDispensePlate well parent must not be None" + 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 = dispense.wells[-1] + elif rot.z % 360 == 0: + ref_well = dispense.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_absolute_location() + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + dispense.offset + ) + # -1 compared to STAR + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 + else: + # Container (trough): center the head + 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_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() + 2.7 - 1 + + liquid_height = position.z + (dispense.liquid_height or 0) + 10 + + volume = dispense.volume + flow_rate = dispense.flow_rate or 250 + transport_air_volume = backend_params.transport_air_volume or 0 + blow_out_air_volume = dispense.blow_out_air_volume or backend_params.blow_out_air_volume or 0 + swap_speed = backend_params.swap_speed or 100 + settling_time = backend_params.settling_time or 5 + type_of_dispensing_mode = backend_params.type_of_dispensing_mode or _dispensing_mode_for_op( + jet=backend_params.jet, empty=backend_params.empty, blow_out=backend_params.blow_out + ) + + traversal = self._driver.traversal_height + + 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( + 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.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10 + ), + minimal_height_at_command_end=round( + (backend_params.minimum_height_at_command_end or traversal) * 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=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=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, + ) + + # =========================================================================== + # Raw firmware command methods + # =========================================================================== + + 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._driver.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 = 5000, + y_position: int = 5000, + z_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + end_z_deposit_position: int = 0, + tip_type: int = 4, + ): + """Initialize 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]. + 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). + """ + + 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._driver.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, + ) + + async def core96_aspiration_of_liquid( + self, + type_of_aspiration: int = 0, + x_position: int = 5000, + y_position: int = 5000, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + lld_search_height: int = 0, + liquid_surface_at_function_without_lld: int = 3900, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + minimum_height: int = 3900, + tube_2nd_section_height_measured_from_zm: int = 0, + tube_2nd_section_ratio: int = 0, + immersion_depth: int = 0, + surface_following_distance: int = 0, + aspiration_volume: int = 0, + aspiration_speed: int = 2000, + transport_air_volume: int = 0, + blow_out_air_volume: int = 1000, + pre_wetting_volume: int = 0, + lld_mode: int = 1, + lld_sensitivity: int = 1, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: int = 0, + surface_following_distance_during_mixing: int = 0, + mix_speed: int = 2000, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + 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 len(tadm_channel_pattern) != 96: + raise ValueError( + f"tadm_channel_pattern must be of length 96, but is '{len(tadm_channel_pattern)}'" + ) + + 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._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_channel_pattern_to_hex(tadm_channel_pattern), + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) + + async def core96_dispensing_of_liquid( + self, + type_of_dispensing_mode: int = 0, + x_position: int = 5000, + y_position: int = 5000, + minimum_height: int = 3900, + tube_2nd_section_height_measured_from_zm: int = 0, + tube_2nd_section_ratio: int = 0, + lld_search_height: int = 0, + liquid_surface_at_function_without_lld: int = 3900, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + immersion_depth: int = 0, + surface_following_distance: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + dispense_volume: int = 0, + dispense_speed: int = 2000, + cut_off_speed: int = 1500, + stop_back_volume: int = 0, + transport_air_volume: int = 0, + blow_out_air_volume: int = 1000, + lld_mode: int = 1, + lld_sensitivity: int = 1, + side_touch_off_distance: int = 0, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: int = 0, + surface_following_distance_during_mixing: int = 0, + mix_speed: int = 2000, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + 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 len(tadm_channel_pattern) != 96: + raise ValueError( + f"tadm_channel_pattern must be of length 96, but is '{len(tadm_channel_pattern)}'" + ) + + 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._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_channel_pattern_to_hex(tadm_channel_pattern), + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) + + async def core96_tip_pick_up( + self, + x_position: int = 5000, + y_position: int = 5000, + tip_type: int = 4, + tip_handling_method: int = 0, + z_deposit_position: int = 0, + 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._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 = 5000, + y_position: int = 5000, + z_deposit_position: int = 0, + 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._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_move_to_defined_position( + self, + x_position: int = 5000, + y_position: int = 5000, + 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._driver.send_command( + module="A1HM", + command="DN", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + ) + + async def core96_wash_tips( + self, + x_position: int = 5000, + y_position: int = 5000, + liquid_surface_at_function_without_lld: int = 3900, + minimum_height: int = 3900, + surface_following_distance_during_mixing: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + mix_volume: int = 0, + 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._driver.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, + ) + + async def core96_empty_washed_tips( + self, + 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._driver.send_command( + module="A1HM", + command="EE", + zl=liquid_surface_at_function_without_lld, + te=minimal_height_at_command_end, + ) + + async def core96_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 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._driver.send_command( + module="A1HM", + command="DL", + xs=x_search_distance, + xv=x_speed, + ) + + async def core96_set_any_parameter(self): + """Set any parameter within the 96 head module.""" + + return await self._driver.send_command( + module="A1HM", + command="AA", + ) + + async def core96_query_tip_presence(self): + """Query Tip presence on the 96 head.""" + + return await self._driver.send_command( + module="A1HM", + command="QA", + ) + + async def core96_request_position(self): + """Request position of the 96 head.""" + + 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. + + Args: + tadm_channel_pattern: TADM Channel pattern. + """ + + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + elif len(tadm_channel_pattern) != 96: + raise ValueError( + f"tadm_channel_pattern must be of length 96, but is '{len(tadm_channel_pattern)}'" + ) + + return await self._driver.send_command( + module="A1HM", + command="VB", + cw=_tadm_channel_pattern_to_hex(tadm_channel_pattern), + ) diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py new file mode 100644 index 00000000000..78f784a6806 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/ipg.py @@ -0,0 +1,491 @@ +"""VantageIPG: Integrated Plate Gripper control for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.arms.backend import OrientableGripperArmBackend +from pylabrobot.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.vantage.driver import VantageDriver + +logger = logging.getLogger(__name__) + + +class VantageIPG(OrientableGripperArmBackend): + """Controls the Integrated Plate Gripper (IPG) on a Hamilton Vantage. + + Implements :class:`OrientableGripperArmBackend`, translating high-level pick/drop + operations into Vantage firmware commands (module ``A1RM``). + + Args: + driver: The VantageDriver instance to send commands through. + """ + + def __init__(self, driver: "VantageDriver"): + self._driver = driver + self._parked: Optional[bool] = None + + @property + def parked(self) -> bool: + return self._parked is True + + # -- CapabilityBackend lifecycle ------------------------------------------- + + async def _on_setup(self) -> None: + """Initialize the IPG if not already initialized, and park if not parked.""" + initialized = await self.ipg_request_initialization_status() + if not initialized: + await self.ipg_initialize() + parked = await self.ipg_get_parking_status() + if not parked: + await self.ipg_park() + self._parked = True + + # -- OrientableGripperArmBackend abstract methods -------------------------- + + @dataclass + class PickUpParams(BackendParams): + grip_strength: int = 81 + plate_width_tolerance: int = 20 + acceleration_index: int = 4 + z_clearance_height: int = 0 + hotel_depth: int = 0 + minimal_height_at_command_end: int = 2840 + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up a plate at the specified location using the IPG. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (unused — IPG grip orientation is set separately via + :meth:`ipg_prepare_gripper_orientation`). + resource_width: Plate width [mm]. + backend_params: VantageIPG.PickUpParams for firmware-specific settings. + """ + if not isinstance(backend_params, VantageIPG.PickUpParams): + backend_params = VantageIPG.PickUpParams() + + open_gripper_position = round(resource_width * 10) + 32 + plate_width = round(resource_width * 10) - 33 + + 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=open_gripper_position, + plate_width=plate_width, + 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 + + @dataclass + class DropParams(BackendParams): + z_clearance_height: int = 0 + press_on_distance: int = 5 + hotel_depth: int = 0 + minimal_height_at_command_end: int = 2840 + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop a plate at the specified location using the IPG. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (unused — IPG grip orientation is set separately via + :meth:`ipg_prepare_gripper_orientation`). + resource_width: Plate width [mm]. Used to compute open gripper position. + backend_params: VantageIPG.DropParams for firmware-specific settings. + """ + if not isinstance(backend_params, VantageIPG.DropParams): + backend_params = VantageIPG.DropParams() + + open_gripper_position = round(resource_width * 10) + 32 + + 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=open_gripper_position, + 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, + ) + self._parked = False + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the IPG (with a held plate) to a defined position. + + Args: + location: Target position [mm]. + direction: Grip direction in degrees (unused for IPG). + backend_params: Unused, reserved for future use. + """ + 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), + ) + self._parked = False + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the IPG.""" + await self.ipg_park() + self._parked = True + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the IPG gripper (release object). + + Args: + gripper_width: Unused for IPG — the gripper simply releases. + backend_params: Unused, reserved for future use. + """ + await self.ipg_release_object() + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the IPG gripper. + + For the IPG, closing the gripper is done as part of :meth:`pick_up_at_location` via + :meth:`ipg_grip_plate`. This method raises :class:`NotImplementedError` because + standalone close is not supported by the IPG firmware. + + Args: + gripper_width: Plate width [mm]. + backend_params: Unused, reserved for future use. + """ + raise NotImplementedError( + "IPG does not support standalone close_gripper. Use pick_up_at_location instead." + ) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Check if the IPG is holding a plate. + + Returns: + True if the IPG is not parked (i.e. presumably holding a plate), False otherwise. + """ + return not await self.ipg_get_parking_status() + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + raise NotImplementedError("IPG does not support request_gripper_location") + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("IPG halt not yet implemented") + + # -- Raw firmware protocol methods ----------------------------------------- + + 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. A1RM corresponds + to "arm". + + Returns: + True if the IPG module is initialized, False otherwise. + """ + resp = await self._driver.send_command(module="A1RM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def ipg_initialize(self): + """Initialize IPG.""" + return await self._driver.send_command(module="A1RM", command="DI") + + async def ipg_park(self): + """Park IPG.""" + return await self._driver.send_command(module="A1RM", command="GP") + + async def ipg_expose_channel_n(self): + """Expose channel n.""" + return await self._driver.send_command(module="A1RM", command="DQ") + + async def ipg_release_object(self): + """Release object.""" + return await self._driver.send_command(module="A1RM", command="DO") + + 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. + + 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._driver.send_command( + module="A1RM", + command="DL", + xs=x_search_distance, + xv=x_speed, + ) + + async def ipg_grip_plate( + self, + x_position: int = 5000, + y_position: int = 5600, + z_position: int = 3600, + 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, + ): + """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._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 = 5000, + y_position: int = 5600, + z_position: int = 3600, + 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, + ): + """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._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_prepare_gripper_orientation( + self, + 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._driver.send_command( + module="A1RM", + command="GA", + gd=grip_orientation, + th=minimal_traverse_height_at_begin_of_command, + ) + + async def ipg_move_to_defined_position( + self, + x_position: int = 5000, + y_position: int = 5600, + 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._driver.send_command( + module="A1RM", + command="DN", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + ) + + async def ipg_set_any_parameter_within_this_module(self): + """Set any parameter within this module.""" + return await self._driver.send_command(module="A1RM", command="AA") + + async def ipg_get_parking_status(self) -> bool: + """Get parking status. + + Returns: + True if parked, False otherwise. + """ + resp = await self._driver.send_command(module="A1RM", command="RG", fmt={"rg": "int"}) + return resp is not None and resp["rg"] == 1 + + async def ipg_query_tip_presence(self): + """Query tip presence.""" + return await self._driver.send_command(module="A1RM", command="QA") + + async def ipg_request_access_range(self, grip_orientation: int = 32): + """Request access range. + + Args: + grip_orientation: Grip orientation. + """ + if not 1 <= grip_orientation <= 44: + raise ValueError("grip_orientation must be in range 1 to 44") + + return await self._driver.send_command( + module="A1RM", + command="QR", + gd=grip_orientation, + ) + + async def ipg_request_position(self, grip_orientation: int = 32): + """Request position. + + Args: + grip_orientation: Grip orientation. + """ + if not 1 <= grip_orientation <= 44: + raise ValueError("grip_orientation must be in range 1 to 44") + + return await self._driver.send_command( + module="A1RM", + command="QI", + gd=grip_orientation, + ) + + async def ipg_request_actual_angular_dimensions(self): + """Request actual angular dimensions.""" + return await self._driver.send_command(module="A1RM", command="RR") + + async def ipg_request_configuration(self): + """Request configuration.""" + return await self._driver.send_command(module="A1RM", command="RS") diff --git a/pylabrobot/hamilton/liquid_handlers/vantage/led_backend.py b/pylabrobot/hamilton/liquid_handlers/vantage/led_backend.py new file mode 100644 index 00000000000..7a0a186aba9 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/led_backend.py @@ -0,0 +1,66 @@ +"""Vantage LED backend: translates LED operations into Vantage firmware commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.led_control.backend import LEDBackend + +if TYPE_CHECKING: + from .driver import VantageDriver + + +@dataclass +class VantageLEDParams(BackendParams): + """Vantage-specific LED parameters. + + Args: + uv: UV channel intensity 0-100. + blink_interval: Blink interval in ms. Only used when mode is "blink". + """ + + uv: int = 0 + blink_interval: Optional[int] = None + + +class VantageLEDBackend(LEDBackend): + """Encodes LED commands for the Vantage master module (C0AM).""" + + def __init__(self, driver: VantageDriver): + self._driver = driver + + async def set_color( + self, + mode: str, + intensity: int, + white: int, + red: int, + green: int, + blue: int, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, VantageLEDParams): + backend_params = VantageLEDParams() + + uv = backend_params.uv + blink_interval = backend_params.blink_interval + + mode_to_li = {"on": 1, "off": 0, "blink": 2} + if mode not in mode_to_li: + raise ValueError(f"Invalid mode {mode!r}. Expected 'on', 'off', or 'blink'.") + if blink_interval is not None and mode != "blink": + raise ValueError("blink_interval is only used when mode is 'blink'.") + + await self._driver.send_command( + module="C0AM", + command="LI", + li=mode_to_li[mode], + os=intensity, + ok=blink_interval or 750, + ol=f"{white} {red} {green} {blue} {uv}", + ) + + async def turn_off(self) -> None: + await self.set_color(mode="off", intensity=0, white=0, red=0, green=0, blue=0) 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..00acac33759 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/loading_cover.py @@ -0,0 +1,48 @@ +"""VantageLoadingCover: Loading cover control for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.vantage.driver import VantageDriver + +logger = logging.getLogger(__name__) + + +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. + + Args: + driver: The VantageDriver instance to send commands through. + """ + + def __init__(self, driver: "VantageDriver"): + self._driver = driver + + async def set_cover(self, cover_open: bool): + """Set the loading cover. + + Args: + cover_open: Whether the cover should be open or closed. + """ + return await self._driver.send_command(module="I1AM", command="LP", lp=not cover_open) + + async def 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._driver.send_command(module="I1AM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + async def initialize(self): + """Initialize the loading cover.""" + return await self._driver.send_command(module="I1AM", command="MI") 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..44e7598deda --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/pip_backend.py @@ -0,0 +1,3049 @@ +"""Vantage PIP backend: translates PIP operations into Vantage firmware commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union + +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.legacy.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_vantage_liquid_class, +) +from pylabrobot.resources import Tip, Well +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.liquid import Liquid + +if TYPE_CHECKING: + from .driver import VantageDriver + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +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 (get_absolute_location) so the driver does not + need a ``deck`` reference. This mirrors HamiltonLiquidHandler._ops_to_fw_positions + but is self-contained. + """ + assert use_channels == sorted(use_channels), "Channels must be sorted." + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + + for i, channel in enumerate(use_channels): + # Pad unused channels with zeros. + 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 per channel index difference). + 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 (Vantage firmware expects at least one extra slot when < num_channels). + 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 + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + + +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. + + If ``explicit`` is None, auto-detect from tip properties for each op. + If ``explicit`` is a list, use it as-is (None entries stay None, matching legacy behavior). + """ + 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 + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +# --------------------------------------------------------------------------- +# VantagePIPBackend +# --------------------------------------------------------------------------- + + +class VantagePIPBackend(PIPBackend): + """Translates PIP operations into Vantage firmware commands via the driver.""" + + def __init__(self, driver: VantageDriver, tip_presences: List[bool]): + self._driver = driver + self._tip_presences = tip_presences + + @property + def num_channels(self) -> int: + return self._driver.num_channels + + async def _on_setup(self) -> None: + """Initialize PIP channels if not already initialized.""" + pip_channels_initialized = await self.pip_request_initialization_status() + if not pip_channels_initialized or any(self._tip_presences): + traversal = self._driver.traversal_height + await self.pip_initialize( + x_position=[7095] * self.num_channels, + y_position=[3891, 3623, 3355, 3087, 2819, 2551, 2283, 2016][:self.num_channels], + begin_z_deposit_position=[int(traversal * 10)] * self.num_channels, + end_z_deposit_position=[1235] * self.num_channels, + minimal_height_at_command_end=[int(traversal * 10)] * self.num_channels, + tip_pattern=[True] * self.num_channels, + tip_type=[1] * self.num_channels, + TODO_DI_2=70, + ) + + # -- pick_up_tips ----------------------------------------------------------- + + @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 + + 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 = set() + for op in ops: + tip = op.tip + if not isinstance(tip, HamiltonTip): + raise TypeError(f"Tip {tip} is not a HamiltonTip.") + tips.add(tip) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + ham_tip = tips.pop() + assert isinstance(ham_tip, HamiltonTip) + ttti = [await self._driver.request_or_assign_tip_type_index(ham_tip)] * len(ops) + + max_z = max( + op.resource.get_absolute_location(x="c", y="c", 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) + + # not sure why this is necessary, but it is according to log files and experiments + if ham_tip.tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif ham_tip.tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + traversal = self._driver.traversal_height + + 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 backend_params.minimal_traverse_height_at_begin_of_command + or [traversal] * len(ops) + ], + minimal_height_at_command_end=[ + round(th * 10) + for th in backend_params.minimal_height_at_command_end or [traversal] * len(ops) + ], + tip_handling_method=[1 for _ in ops], # always appears to be 1 + blow_out_air_volume=[0] * len(ops), + ) + + # -- drop_tips -------------------------------------------------------------- + + @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 + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to a resource.""" + 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(x="c", y="c", z="b").z + op.offset.z for op in ops + ) + + traversal = self._driver.traversal_height + + 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(th * 10) + for th in backend_params.minimal_traverse_height_at_begin_of_command + or [traversal] * len(ops) + ], + minimal_height_at_command_end=[ + round(th * 10) + for th in backend_params.minimal_height_at_command_end or [traversal] * len(ops) + ], + tip_handling_method=[0 for _ in ops], # Always appears to be 0, even in trash. + TODO_TR_2=0, + ) + + # -- aspirate --------------------------------------------------------------- + + @dataclass + class AspirateParams(BackendParams): + """Vantage-specific parameters for ``aspirate``. + + See :meth:`pip_aspirate` (the firmware command) for parameter documentation. This dataclass + 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: + jet: Whether to search for a "jet" liquid class. + blow_out: Whether to search for a "blow out" liquid class. 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). + hamilton_liquid_classes: The Hamilton 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. + """ + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + type_of_aspiration: Optional[List[int]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = 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 + 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 + limit_curve_index: Optional[List[int]] = None + recording_mode: int = 0 + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None + minimal_height_at_command_end: Optional[List[float]] = None + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = 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. + backend_params: Vantage-specific aspiration parameters. + """ + + if not isinstance(backend_params, VantagePIPBackend.AspirateParams): + backend_params = VantagePIPBackend.AspirateParams() + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + jet = backend_params.jet or [False] * n + blow_out = backend_params.blow_out or [False] * n + + # Resolve liquid classes (auto-detect from tip if not provided). + hlcs = _resolve_liquid_classes(backend_params.hamilton_liquid_classes, ops, + jet=jet, blow_out=blow_out, ) + + # Well bottoms (absolute z + material thickness). + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", z="b").z + + op.offset.z + + op.resource.material_z_thickness + for op in ops + ] + + # LLD search height. -1 compared to STAR. + lld_search_heights = backend_params.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) + ] + + 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) + ] + + # correct volumes using the liquid class if not disabled + disable_volume_correction = backend_params.disable_volume_correction or [False] * n + 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) + ] + + 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) + ] + + traversal = self._driver.traversal_height + + await self.pip_aspirate( + x_position=x_positions, + y_position=y_positions, + type_of_aspiration=backend_params.type_of_aspiration or [0] * n, + tip_pattern=channels_involved, + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in backend_params.minimal_traverse_height_at_begin_of_command or [traversal] * n + ], + minimal_height_at_command_end=[ + round(th * 10) + for th in backend_params.minimal_height_at_command_end or [traversal] * n + ], + 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] * n + ], + 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] * n + ], + tube_2nd_section_height_measured_from_zm=[ + round(t2sh * 10) + for t2sh in backend_params.tube_2nd_section_height_measured_from_zm or [0] * n + ], + tube_2nd_section_ratio=[ + round(t2sr * 10) for t2sr in backend_params.tube_2nd_section_ratio or [0] * n + ], + minimum_height=[ + round(wb * 10) for wb in backend_params.minimum_height or well_bottoms + ], + immersion_depth=[round(id_ * 10) for id_ in backend_params.immersion_depth or [0] * n], + surface_following_distance=[ + round(sfd * 10) for sfd in backend_params.surface_following_distance or [0] * n + ], + 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] * n + ], + lld_mode=backend_params.lld_mode or [0] * n, + lld_sensitivity=backend_params.lld_sensitivity or [4] * n, + pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [4] * n, + aspirate_position_above_z_touch_off=[ + round(apz * 10) + for apz in backend_params.aspirate_position_above_z_touch_off or [0.5] * n + ], + swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [2] * n], + settling_time=[round(st * 10) for st in backend_params.settling_time or [1] * n], + 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 backend_params.mix_position_in_z_direction_from_liquid_surface or [0] * n + ], + 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 backend_params.surface_following_distance_during_mixing or [0] * n + ], + TODO_DA_5=backend_params.TODO_DA_5 or [0] * n, + capacitive_mad_supervision_on_off=( + backend_params.capacitive_mad_supervision_on_off or [0] * n + ), + pressure_mad_supervision_on_off=( + backend_params.pressure_mad_supervision_on_off or [0] * n + ), + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off or 0, + limit_curve_index=backend_params.limit_curve_index or [0] * n, + recording_mode=backend_params.recording_mode or 0, + ) + + # -- dispense --------------------------------------------------------------- + + @dataclass + class DispenseParams(BackendParams): + """Vantage-specific parameters for ``dispense``. + + See :meth:`pip_dispense` (the firmware command) for parameter documentation. This dataclass + serves as a wrapper for that command. + + Args: + 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. + hamilton_liquid_classes: The Hamilton 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. + """ + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + empty: Optional[List[bool]] = None + type_of_dispensing_mode: Optional[List[int]] = 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 + 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 + 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 + 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 + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = 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 dispense operations. + use_channels: The channels to use. + backend_params: Vantage-specific dispense parameters. + """ + + if not isinstance(backend_params, VantagePIPBackend.DispenseParams): + backend_params = VantagePIPBackend.DispenseParams() + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + jet = backend_params.jet or [False] * n + empty = backend_params.empty or [False] * n + blow_out = backend_params.blow_out or [False] * n + + # Resolve liquid classes. + hlcs = _resolve_liquid_classes(backend_params.hamilton_liquid_classes, ops, + jet=jet, blow_out=blow_out, ) + + # Well bottoms. + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", 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 height. -1 compared to STAR. + lld_search_heights = backend_params.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) + ] + + # correct volumes using the liquid class + disable_volume_correction = backend_params.disable_volume_correction or [False] * n + 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) + ] + + 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 [ + _dispensing_mode_for_op(jet=jet[i], empty=empty[i], blow_out=blow_out[i]) + for i in range(n) + ] + + traversal = self._driver.traversal_height + + 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] * n + ], + immersion_depth=[round(id_ * 10) for id_ in backend_params.immersion_depth or [0] * n], + surface_following_distance=[ + round(sfd * 10) for sfd in backend_params.surface_following_distance or [2.1] * n + ], + tube_2nd_section_height_measured_from_zm=[ + round(t2sh * 10) + for t2sh in backend_params.tube_2nd_section_height_measured_from_zm or [0] * n + ], + tube_2nd_section_ratio=[ + round(t2sr * 10) for t2sr in backend_params.tube_2nd_section_ratio or [0] * n + ], + minimal_traverse_height_at_begin_of_command=[ + round(mth * 10) + for mth in backend_params.minimal_traverse_height_at_begin_of_command + or [traversal] * n + ], + minimal_height_at_command_end=[ + round(mh * 10) + for mh in backend_params.minimal_height_at_command_end or [traversal] * n + ], + 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] * n], + stop_back_volume=[round(sbv * 100) for sbv in backend_params.stop_back_volume or [0] * n], + 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] * n, + 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] * n + ], + lld_sensitivity=backend_params.lld_sensitivity or [1] * n, + pressure_lld_sensitivity=backend_params.pressure_lld_sensitivity or [1] * n, + swap_speed=[round(ss * 10) for ss in backend_params.swap_speed or [1] * n], + settling_time=[round(st * 10) for st in backend_params.settling_time or [0] * n], + 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 backend_params.mix_position_in_z_direction_from_liquid_surface or [0] * n + ], + mix_speed=[ + round(op.mix.flow_rate * 10) if op.mix is not None else 10 for op in ops + ], + surface_following_distance_during_mixing=[ + round(sfdm * 10) + for sfdm in backend_params.surface_following_distance_during_mixing or [0] * n + ], + TODO_DD_2=backend_params.TODO_DD_2 or [0] * n, + tadm_algorithm_on_off=backend_params.tadm_algorithm_on_off or 0, + limit_curve_index=backend_params.limit_curve_index or [0] * n, + recording_mode=backend_params.recording_mode or 0, + ) + + # -- can_pick_up_tip -------------------------------------------------------- + + 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 + + # -- request_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. + """ + presences = await self._driver.query_tip_presence() + result: List[Optional[bool]] = list(presences) + return result + + # =========================================================================== + # Firmware command methods (raw protocol) + # =========================================================================== + + 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._driver.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, + 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._driver.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=TODO_DI_2, + ) + + async def pip_aspirate( + self, + x_position: List[int], + y_position: List[int], + type_of_aspiration: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + clot_detection_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + minimum_height: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + TODO_DA_2: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + pre_wetting_volume: Optional[List[int]] = 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[int]] = None, + TODO_DA_4: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = 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, + 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). + """ + + 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._driver.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 parameter 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, + ) + + async def pip_dispense( + self, + x_position: List[int], + y_position: List[int], + type_of_dispensing_mode: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + minimum_height: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + lld_mode: Optional[List[int]] = None, + side_touch_off_distance: int = 0, + dispense_position_above_z_touch_off: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = 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, + ): + """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) + . + """ + + 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._driver.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, + ) + + 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[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + clot_detection_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + minimum_height: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + TODO_DM_3: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + pre_wetting_volume: Optional[List[int]] = None, + lld_mode: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = 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 & 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) + . + """ + + 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 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 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") + + 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 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 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 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_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") + + 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 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 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 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 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_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") + + 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._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=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, + ) + + async def dispense_on_fly( + self, + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + 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, + 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, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + 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). + """ + + 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") + + 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 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 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 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 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._driver.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, + ) + + async def nano_pulse_dispense( + self, + x_position: List[int], + y_position: List[int], + TODO_DB_0: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = 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[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). + """ + + 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") + + 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 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 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 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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") + + 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._driver.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, + ) + + async def wash_tips( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + soak_time: int = 0, + 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]. + """ + + 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 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 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 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 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 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") + + 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._driver.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, + ) + + async def pip_tip_pick_up( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + tip_type: Optional[List[int]] = None, + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + 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") + + 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._driver.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( + 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_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + 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") + + 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._driver.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, + ) + + # =========================================================================== + # Positioning / teach-in methods + # =========================================================================== + + async def search_for_teach_in_signal_in_x_direction( + self, + channel_index: int = 1, + 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._driver.send_command( + module="A1PM", + command="DL", + pn=channel_index, + xs=x_search_distance, + xv=x_speed, + ) + + 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]. + """ + + 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._driver.send_command( + module="A1PM", + command="DY", + yp=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]. + """ + + 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._driver.send_command( + module="A1PM", + command="DZ", + zp=z_position, + ) + + async def position_single_channel_in_y_direction( + self, + 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._driver.send_command( + module="A1PM", + command="DV", + pn=channel_index, + yj=y_position, + ) + + async def position_single_channel_in_z_direction( + self, + 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._driver.send_command( + module="A1PM", + command="DU", + pn=channel_index, + zj=z_position, + ) + + async def move_to_defined_position( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + 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]. + """ + + 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._driver.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, + ) + + async def teach_rack_using_channel_n( + self, + channel_index: int = 1, + gap_center_x_direction: int = 0, + gap_center_y_direction: int = 3000, + 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") + + 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._driver.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, + ) + + 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._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: int = 0, + TODO_DC_1: int = 3000, + tip_type: Optional[List[int]] = None, + TODO_DC_2: Optional[List[int]] = None, + z_deposit_position: Optional[List[int]] = None, + 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") + + 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._driver.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, + ) + + async def discard_core_gripper_tool( + self, + gripper_tool_x_position: int = 0, + first_gripper_tool_y_pos: int = 3000, + tip_type: Optional[List[int]] = None, + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + 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") + + 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._driver.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, + ) + + # =========================================================================== + # PIP gripper methods (CoRe gripper on pip channels) + # =========================================================================== + + async def grip_plate( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + z_speed: int = 1287, + open_gripper_position: int = 860, + plate_width: int = 800, + acceleration_index: int = 4, + grip_strength: int = 30, + 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") + + 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._driver.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, + ) + + async def put_plate( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + press_on_distance: int = 5, + z_speed: int = 1287, + open_gripper_position: int = 860, + 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") + + 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._driver.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, + ) + + async def move_to_position( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + 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") + + 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._driver.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, + ) + + async def release_object( + self, + first_pip_channel_node_no: int = 1, + ): + """Release object + + 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._driver.send_command( + module="A1PM", + command="DO", + pa=first_pip_channel_node_no, + ) + + # =========================================================================== + # Query methods + # =========================================================================== + + async def set_any_parameter_within_this_module(self): + """Set any parameter within this module""" + + return await self._driver.send_command( + module="A1PM", + command="AA", + ) + + async def request_y_positions_of_all_channels(self): + """Request Y Positions of all channels""" + + return await self._driver.send_command( + module="A1PM", + command="RY", + ) + + async def request_y_position_of_channel_n(self, channel_index: int = 1): + """Request Y Position of channel n""" + + return await self._driver.send_command( + module="A1PM", + command="RB", + pn=channel_index, + ) + + async def request_z_positions_of_all_channels(self): + """Request Z Positions of all channels""" + + return await self._driver.send_command( + module="A1PM", + command="RZ", + ) + + async def request_z_position_of_channel_n(self, channel_index: int = 1): + """Request Z Position of channel n""" + + return await self._driver.send_command( + module="A1PM", + command="RD", + pn=channel_index, + ) + + async def request_height_of_last_lld(self): + """Request height of last LLD""" + + return await self._driver.send_command( + module="A1PM", + command="RL", + ) + + async def request_channel_dispense_on_fly_status(self): + """Request channel dispense on fly status""" + + return await self._driver.send_command( + module="A1PM", + command="QF", + ) 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/vantage.py b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py new file mode 100644 index 00000000000..32393015caf --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/vantage.py @@ -0,0 +1,104 @@ +"""Vantage device: wires VantageDriver backends to PIP/Head96/IPG/LED capability frontends.""" + +import asyncio +import random +from typing import Optional + +from pylabrobot.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.led_control import LEDControlCapability +from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources.hamilton import HamiltonDeck + +from .chatterbox import VantageChatterboxDriver +from .driver import VantageDriver +from .led_backend import VantageLEDParams + + +class Vantage(Device): + """Hamilton Vantage liquid handler. + + User-facing device that wires capability frontends (PIP, Head96, IPG, LED) to the + VantageDriver's backends after hardware discovery during setup(). + """ + + def __init__( + self, + deck: HamiltonDeck, + chatterbox: bool = False, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + driver = VantageChatterboxDriver() if chatterbox else VantageDriver() + super().__init__(driver=driver) + self._driver: VantageDriver = driver + self.deck = deck + self._skip_loading_cover = skip_loading_cover + self._skip_core96 = skip_core96 + self._skip_ipg = skip_ipg + self.pip: PIP # set in setup() + self.head96: Optional[Head96Capability] = None # set in setup() if installed + self.iswap: Optional[OrientableArm] = None # set in setup() if installed (IPG) + self.led: LEDControlCapability # set in setup() + + async def setup(self): + await self._driver.setup( + skip_loading_cover=self._skip_loading_cover, + skip_core96=self._skip_core96, + skip_ipg=self._skip_ipg, + ) + + # PIP is always present. + self.pip = PIP(backend=self._driver.pip) + self._capabilities = [self.pip] + + # Head96 only if installed. + if self._driver.head96 is not None: + self.head96 = Head96Capability(backend=self._driver.head96) + self._capabilities.append(self.head96) + + # IPG only if installed. + if self._driver.ipg is not None: + self.iswap = OrientableArm(backend=self._driver.ipg, reference_resource=self.deck) + self._capabilities.append(self.iswap) + + # LED is always present. + self.led = LEDControlCapability(backend=self._driver.led) + self._capabilities.append(self.led) + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._driver.stop() + self._setup_finished = False + self.head96 = None + self.iswap = None + + 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.led.set_color( + "on", intensity=100, white=100, red=100, green=0, blue=0, + backend_params=VantageLEDParams(uv=100), + ) + print("You lost.") + else: + await self.led.set_color("on", intensity=100, white=100, red=0, green=100, blue=0) + print("You won.") + + await asyncio.sleep(5) + await self.led.set_color("on", intensity=100, white=100, red=100, green=100, blue=100) 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..650d8b8f911 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/vantage/x_arm.py @@ -0,0 +1,221 @@ +"""VantageXArm: X-arm positioning control for Hamilton Vantage liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.vantage.driver import VantageDriver + +logger = logging.getLogger(__name__) + + +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. + + Args: + driver: The VantageDriver instance to send commands through. + """ + + def __init__(self, driver: "VantageDriver"): + self._driver = driver + + async def initialize(self): + """Initialize the X-arm.""" + return await self._driver.send_command(module="A1XM", command="XI") + + async def move_to_x_position( + self, + x_position: int = 5000, + x_speed: int = 25000, + ): + """Move arm to X position. + + Args: + x_position: X Position [0.1mm]. + x_speed: X speed [0.1mm/s]. + """ + 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") + + return await self._driver.send_command( + module="A1XM", + command="XP", + xp=x_position, + xv=x_speed, + ) + + async def move_to_x_position_with_all_attached_components_in_z_safety_position( + self, + x_position: int = 5000, + x_speed: int = 25000, + TODO_XA_1: int = 1, + ): + """Move arm to X position with all attached components in Z safety position. + + Args: + x_position: X Position [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XA_1: (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 1 <= TODO_XA_1 <= 25000: + raise ValueError("TODO_XA_1 must be in range 1 to 25000") + + return await self._driver.send_command( + module="A1XM", + command="XA", + xp=x_position, + xv=x_speed, + xx=TODO_XA_1, + ) + + async def move_arm_relatively_in_x( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + TODO_XS_1: int = 1, + ): + """Move arm relatively in X. + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XS_1: (0). + """ + 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._driver.send_command( + module="A1XM", + command="XS", + xs=x_search_distance, + xv=x_speed, + xx=TODO_XS_1, + ) + + async def search_x_for_teach_signal( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + TODO_XT_1: int = 1, + ): + """Search X for teach signal. + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XT_1: (0). + """ + 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._driver.send_command( + module="A1XM", + command="XT", + xs=x_search_distance, + xv=x_speed, + xx=TODO_XT_1, + ) + + async def 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._driver.send_command( + module="A1XM", + command="XL", + xl=TODO_XL_1, + ) + + async def turn_x_drive_off(self): + """Turn X drive off.""" + return await self._driver.send_command(module="A1XM", command="XO") + + async def send_message_to_motion_controller( + self, + TODO_BD_1: str = "", + ): + """Send message to motion controller. + + Args: + TODO_BD_1: (0). + """ + return await self._driver.send_command( + module="A1XM", + command="BD", + bd=TODO_BD_1, + ) + + async def 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._driver.send_command( + module="A1XM", + command="AA", + xm=TODO_AA_1, + xt=TODO_AA_2, + ) + + async def 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 + :meth:`move_to_x_position`. + """ + return await self._driver.send_command(module="A1XM", command="RX") + + async def request_error_code(self): + """Request X-arm error code.""" + return await self._driver.send_command(module="A1XM", command="RE") + + async def 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._driver.send_command( + module="A1RM", + command="QL", + lj=TODO_QL_1, + ln=TODO_QL_2, + ) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index 07c02e4fc0c..c336c9ca8d3 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -378,6 +378,23 @@ def __init__( self._num_channels: Optional[int] = None self._traversal_height: float = 245.0 + # New-architecture backends, created during setup(). + self._pip_backend = None + self._head96_backend = None + self._ipg_backend = None + self._led_backend = None + self._loading_cover = None + self._x_arm_subsystem = None + + @property + def traversal_height(self) -> float: + """Traversal height in mm (used by new-architecture backends).""" + return self._traversal_height + + async def request_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: + """Alias for legacy get_or_assign_tip_type_index (new backends use this name).""" + return await self.get_or_assign_tip_type_index(tip) + @property def module_id_length(self) -> int: return 4 @@ -417,41 +434,34 @@ async def setup( if not arm_initialized: await self.arm_pre_initialize() - # TODO: check which modules are actually installed. - - 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, - ) + # Create new-architecture backends, passing self as the driver (we ARE a + # HamiltonLiquidHandler with send_command, num_channels, etc.). + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + from pylabrobot.hamilton.liquid_handlers.vantage.head96_backend import VantageHead96Backend + from pylabrobot.hamilton.liquid_handlers.vantage.ipg import VantageIPG + from pylabrobot.hamilton.liquid_handlers.vantage.led_backend import VantageLEDBackend + from pylabrobot.hamilton.liquid_handlers.vantage.loading_cover import VantageLoadingCover + from pylabrobot.hamilton.liquid_handlers.vantage.x_arm import VantageXArm - 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, - ) + self._pip_backend = VantagePIPBackend(self, tip_presences=tip_presences) + await self._pip_backend._on_setup() + + self._loading_cover = VantageLoadingCover(driver=self) + 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() + + if not skip_core96: + self._head96_backend = VantageHead96Backend(self) + await self._head96_backend._on_setup() 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() + self._ipg_backend = VantageIPG(driver=self) + await self._ipg_backend._on_setup() + + self._led_backend = VantageLEDBackend(self) + self._x_arm_subsystem = VantageXArm(driver=self) @property def num_channels(self) -> int: @@ -482,43 +492,12 @@ 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 + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + 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, + ) + return await self._pip_backend.pick_up_tips(ops, use_channels, backend_params=backend_params) # @need_iswap_parked async def drop_tips( @@ -529,34 +508,12 @@ async def drop_tips( 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 + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + 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, + ) + return await self._pip_backend.drop_tips(ops, use_channels, backend_params=backend_params) def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: """Assert that resources are in a valid location for pipetting.""" @@ -606,159 +563,52 @@ 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. - """ - - 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. " - "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 - 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, + """Aspirate from (a) resource(s). Delegates to VantagePIPBackend.""" + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + backend_params = VantagePIPBackend.AspirateParams( + hamilton_liquid_classes=hlcs, + disable_volume_correction=disable_volume_correction, + type_of_aspiration=type_of_aspiration, + jet=jet or [False] * len(ops), + blow_out=blow_out or [False] * len(ops), + 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, + 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, + limit_curve_index=limit_curve_index, + recording_mode=recording_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, ) + return await self._pip_backend.aspirate(ops, use_channels, backend_params=backend_params) async def dispense( self, ops: List[SingleChannelDispense], use_channels: List[int], jet: Optional[List[bool]] = None, - blow_out: Optional[List[bool]] = None, # "empty" in the VENUS liquid editor - empty: Optional[List[bool]] = None, # truly "empty", does not exist in liquid editor, dm4 + 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, minimum_height: Optional[List[float]] = None, @@ -791,161 +641,43 @@ 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. - """ - - 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. " - "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 - 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, + """Dispense to (a) resource(s). Delegates to VantagePIPBackend.""" + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + backend_params = VantagePIPBackend.DispenseParams( + hamilton_liquid_classes=hlcs, + disable_volume_correction=disable_volume_correction, + jet=jet or [False] * len(ops), + blow_out=blow_out or [False] * len(ops), + empty=empty or [False] * len(ops), 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, + 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, + 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, + 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, + 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, ) + return await self._pip_backend.dispense(ops, use_channels, backend_params=backend_params) async def pick_up_tips96( self, diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py index 3ca80f942bb..0e7dd375253 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py @@ -220,6 +220,13 @@ async def setup(self) -> None: # type: ignore self._num_arms = 1 self._head96_installed = True + # Create new-architecture backends so that forwarding works. + from pylabrobot.hamilton.liquid_handlers.vantage.pip_backend import VantagePIPBackend + from pylabrobot.hamilton.liquid_handlers.vantage.head96_backend import VantageHead96Backend + + self._pip_backend = VantagePIPBackend(self, tip_presences=[False] * 8) + self._head96_backend = VantageHead96Backend(self) + async def send_command( self, module: str,