From e64bae33d1c2cc8b6ad9ab2560761540dca244cb Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 22 Jan 2025 11:24:58 -0800 Subject: [PATCH 01/42] prep --- .vscode/settings.json | 1 + .../liquid_handling/backends/hamilton/prep.py | 3559 +++++++++++++++++ .../backends/hamilton/prep_tests.py | 267 ++ pylabrobot/resources/deck.py | 6 +- pylabrobot/resources/hamilton/__init__.py | 1 + .../resources/hamilton/hamilton_decks.py | 30 + 6 files changed, 3861 insertions(+), 3 deletions(-) create mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep.py create mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep_tests.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 714c5081890..217e3aaab19 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "klass", "labware", "modbus", + "pipettor", "pylabrobot", "pytest", "subclassing", diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep.py b/pylabrobot/liquid_handling/backends/hamilton/prep.py new file mode 100644 index 00000000000..b315ac291e2 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep.py @@ -0,0 +1,3559 @@ +import random +import socket +import struct +import time +from dataclasses import dataclass +from enum import Enum, IntEnum +from typing import Any, List, Optional, Tuple, Union + +from pylabrobot.liquid_handling.backends import LiquidHandlerBackend +from pylabrobot.liquid_handling.standard import ( + Aspiration, + AspirationContainer, + AspirationPlate, + Dispense, + DispenseContainer, + DispensePlate, + Drop, + DropTipRack, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, +) + + +class ParameterTypes(Enum): + Void = 0 + Int8Bit = 1 + Int16Bit = 2 + Int32Bit = 3 + UInt8Bit = 4 + UInt16Bit = 5 + UInt32Bit = 6 + String = 15 + UInt8Array = 22 + Bool = 23 + Int8Array = 24 + Int16Array = 25 + UInt16Array = 26 + Int32Array = 27 + UInt32Array = 28 + BoolArray = 29 + Structure = 30 + StructureArray = 31 + Enum = 32 + HcResult = 33 + StringArray = 34 + EnumArray = 35 + Int64Bit = 36 + UInt64Bit = 37 + Int64Array = 38 + UInt64Array = 39 + Real32Bit = 40 + Real64Bit = 41 + Real32Array = 42 + Real64Array = 43 + + +class StructureWrapper: + def __init__(self, data=None): + self.members = [] # List of member data + self.m_member_names = [] # List of member names + + if data is not None: + offset = 0 + while offset < len(data): + fragment_length = struct.unpack_from("H", data, offset + 2)[0] + data_fragment = parse_data_fragment(data[offset:]) + + if data_fragment["format"] == ParameterTypes.EnumArray: + enumeration_wrapper_array = data_fragment["fragment_data"] + # undefined? + if enumeration_wrapper_array is None: + enumeration_wrapper_array = ["???"] + self.members.append(enumeration_wrapper_array) + else: + self.members.append(data_fragment["fragment_data"]) + + self.m_member_names.append("") + offset += fragment_length + 4 + + def encode(self): + encoded_data = b"" + for member in self.members: + encoded_data += encode_data_fragment(member["value"], member["type"]) + return encoded_data + + +# TODO: +DataFragment = dict + + +def parse_data_fragment(data: bytes) -> DataFragment: + padded_bit_field = 0x1 + parameter_type = ParameterTypes(struct.unpack_from("B", data)[0]) + flgas = struct.unpack_from("B", data, 1)[0] + length = struct.unpack_from("H", data, 2)[0] + is_padded = (flgas & padded_bit_field) == padded_bit_field + + if parameter_type == ParameterTypes.Int8Bit: + fragment_data = struct.unpack_from("b", data, 4)[0] + elif parameter_type == ParameterTypes.Int16Bit: + fragment_data = struct.unpack_from("h", data, 4)[0] + elif parameter_type == ParameterTypes.Int32Bit: + fragment_data = struct.unpack_from("i", data, 4)[0] + elif parameter_type == ParameterTypes.UInt8Bit: + fragment_data = struct.unpack_from("B", data, 4)[0] + elif parameter_type == ParameterTypes.UInt16Bit or parameter_type == ParameterTypes.HcResult: + fragment_data = struct.unpack_from("H", data, 4)[0] + elif parameter_type == ParameterTypes.UInt32Bit: + fragment_data = struct.unpack_from("I", data, 4)[0] + elif parameter_type == ParameterTypes.String: + length_adj = length - 1 if is_padded else length + if length_adj > 0: + fragment_data = data[4 : 4 + length_adj - 1].decode("ascii") + else: + fragment_data = "" + elif parameter_type == ParameterTypes.UInt8Array: + length_adj = length - 1 if is_padded else length + fragment_data = list(data[4 : 4 + length_adj]) + elif parameter_type == ParameterTypes.Bool: + fragment_data = struct.unpack_from("?", data, 4)[0] + elif parameter_type == ParameterTypes.Int8Array: + length_adj = length - 1 if is_padded else length + fragment_data = list(struct.unpack_from(f"{length_adj}b", data, 4)) + elif parameter_type == ParameterTypes.Int16Array: + fragment_data = list(struct.unpack_from(f"{length // 2}h", data, 4)) + elif parameter_type == ParameterTypes.UInt16Array: + fragment_data = list(struct.unpack_from(f"{length // 2}H", data, 4)) + elif parameter_type == ParameterTypes.Int32Array: + fragment_data = list(struct.unpack_from(f"{length // 4}i", data, 4)) + elif parameter_type == ParameterTypes.UInt32Array: + fragment_data = list(struct.unpack_from(f"{length // 4}I", data, 4)) + elif parameter_type == ParameterTypes.BoolArray: + # new_types + length_adj = length - 1 if is_padded else length + fragment_data = [struct.unpack_from("?", data, 4 + i)[0] for i in range(length_adj)] + elif parameter_type == ParameterTypes.Real32Bit: + fragment_data = struct.unpack_from("f", data, 4)[0] + elif parameter_type == ParameterTypes.Real64Bit: + fragment_data = struct.unpack_from("d", data, 4)[0] + elif parameter_type == ParameterTypes.Real32Array: + fragment_data = list(struct.unpack_from(f"{length // 4}f", data, 4)) + elif parameter_type == ParameterTypes.Real64Array: + fragment_data = list(struct.unpack_from(f"{length // 8}d", data, 4)) + elif parameter_type == ParameterTypes.Structure: + struct_length = struct.unpack_from("H", data, 2)[0] + struct_data = data[4 : 4 + struct_length] + fragment_data = StructureWrapper(struct_data) + elif parameter_type == ParameterTypes.StructureArray: + struct_length = struct.unpack_from("H", data, 2)[0] + struct_data = data[4 : 4 + struct_length] + structure_wrappers = [] + current_offset = 0 + + while current_offset < len(struct_data): + frag_length = struct.unpack_from("H", struct_data, current_offset + 2)[0] + fragment = parse_data_fragment(struct_data[current_offset:]) + structure_wrappers.append(fragment["fragment_data"]) + current_offset += frag_length + 4 + + fragment_data = structure_wrappers + elif parameter_type == ParameterTypes.Enum: + # decode as 32-bit unsigned integer + fragment_data = struct.unpack_from("I", data, 4)[0] + elif parameter_type == ParameterTypes.EnumArray: + fragment_data = list(struct.unpack_from(f"{length // 4}I", data, 4)) + elif parameter_type == ParameterTypes.Int64Array: + fragment_data = list(struct.unpack_from(f"{length // 8}q", data, 4)) + else: + raise ValueError(f"Unsupported parameter type: {parameter_type}") + + return { + "format": parameter_type, + "flags": flgas, + "length": length + 4, # total length includes the format, flags, and length fields + "is_padded": is_padded, + "fragment_data": fragment_data, + } + + +def encode_data_fragment(obj: Any, parameter_type: ParameterTypes, padded=False) -> bytes: + format = struct.pack("B", parameter_type.value) + data = b"" + flags = 0 + + if parameter_type == ParameterTypes.Int8Bit: + data = struct.pack("b", obj) + elif parameter_type == ParameterTypes.Int16Bit: + data = struct.pack("h", obj) + elif parameter_type == ParameterTypes.Int32Bit: + data = struct.pack("i", obj) + elif parameter_type == ParameterTypes.UInt8Bit: + data = struct.pack("B", obj) + padded = True + elif parameter_type == ParameterTypes.UInt16Bit: + data = struct.pack("H", obj) + elif parameter_type == ParameterTypes.UInt32Bit: + data = struct.pack("I", obj) + elif parameter_type == ParameterTypes.String: + data = obj.encode("ascii") + b"\x00" + elif parameter_type == ParameterTypes.UInt8Array: + data = bytes(obj) + elif parameter_type == ParameterTypes.Bool: + data = struct.pack("?", obj) + padded = True + elif parameter_type == ParameterTypes.Int8Array: + data = struct.pack(f"{len(obj)}b", *obj) + elif parameter_type == ParameterTypes.Int16Array: + data = struct.pack(f"{len(obj)}h", *obj) + elif parameter_type == ParameterTypes.UInt16Array: + data = struct.pack(f"{len(obj)}H", *obj) + elif parameter_type == ParameterTypes.Int32Array: + data = struct.pack(f"{len(obj)}i", *obj) + elif parameter_type == ParameterTypes.UInt32Array: + data = struct.pack(f"{len(obj)}I", *obj) + elif parameter_type == ParameterTypes.BoolArray: + data = b"".join([struct.pack("?", b) for b in obj]) + elif parameter_type == ParameterTypes.Real32Bit: + data = struct.pack("f", obj) + elif parameter_type == ParameterTypes.Real64Bit: + data = struct.pack("d", obj) + elif parameter_type == ParameterTypes.Real32Array: + data = struct.pack(f"{len(obj)}f", *obj) + elif parameter_type == ParameterTypes.Real64Array: + data = struct.pack(f"{len(obj)}d", *obj) + elif parameter_type == ParameterTypes.Structure: + struct_data = obj.encode() + data = struct_data + elif parameter_type == ParameterTypes.StructureArray: + data = b"".join([encode_data_fragment(o, ParameterTypes.Structure) for o in obj]) + elif parameter_type == ParameterTypes.Enum: + # encode as 32-bit unsigned integer + data = struct.pack("I", obj) + elif parameter_type == ParameterTypes.EnumArray: + data = struct.pack(f"{len(obj)}I", *obj) + else: + raise ValueError(f"Unsupported parameter type: {parameter_type}") + + if padded: + flags |= Prep.HoiPacket2.BitField.Padded + data += b"\x00" + + length = len(data) + + return format + struct.pack("B", flags) + struct.pack("H", length) + data + + +class Prep(LiquidHandlerBackend): + def __init__(self, host: str = "192.168.100.102", port: int = 2000): + self.pipettor_source = Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0006)) + self.pipettor_destination = Prep.HarpPacket.HarpAddress((0xE000, 0x0001, 0x1000)) + + self.source_address = Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0004)) + self.destination_address = Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0x1500)) + + self._id = 0 + self.host = host + self.port = port + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + async def setup(self): + self.socket.connect((self.host, self.port)) + self.socket.settimeout(30) + + await self.initialize( + tip_drop_params=Prep.InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + smart=False, + ) + + await super().setup() + + async def stop(self): + self.socket.close() + await super().stop() + + def _generate_id(self) -> int: + """continuously generate unique ids 0 <= x <= 0xff.""" + self._id += 1 + return self._id % 0xFF + + def _assemble_command( + self, + command_id: int, + parameters: List[Tuple[ParameterTypes]], + harp_source: "Prep.HarpPacket.HarpAddress", + harp_destination: "Prep.HarpPacket.HarpAddress", + hoi_action: "Prep.HoiPacket2.Hoi2Action", + ) -> bytes: + hoi_packet = Prep.HoiPacket2( + interface_id=1, + action=hoi_action, + action_id=command_id, + version=0, + data_fragments=[ + encode_data_fragment(value, parameter_type) for value, parameter_type in parameters + ], + ) + + harp_packet = Prep.HarpPacket( + source=harp_source, + destination=harp_destination, + sequence_number=self._generate_id(), + reserved_1=0, + protocol=Prep.HarpPacket.HarpTransportableProtocol.Hoi2, + action=Prep.HarpPacket.Action.create( + Prep.HarpPacket.ResponseRequired.Yes, Prep.HarpPacket.PayloadDescription.CommandRequest + ), + options=[], # TODO: calculate this + version=0, + reserved_2=0, + payload=hoi_packet.encode(), + ) + + ip_packet = Prep.IpPacket( + protocol=Prep.IpPacket.TransportableProtocol.Harp2, + version=(3, 0), + options=None, + payload=harp_packet.encode(), + ) + + return ip_packet.encode() + + def _decode_response(self, response: bytes): + try: + ip_packet = Prep.IpPacket.decode(response) + except ValueError as e: + raise ValueError(f"Failed to decode response: {e}") + + if ip_packet.protocol == Prep.IpPacket.TransportableProtocol.Harp2: + harp_packet = Prep.HarpPacket.decode(ip_packet.payload) + else: + raise ValueError(f"protocol {ip_packet.protocol} not supported") + + if harp_packet.protocol != Prep.HarpPacket.HarpTransportableProtocol.Hoi2: + raise ValueError(f"protocol {harp_packet.protocol} not supported") + + try: + hoi_packet = Prep.HoiPacket2.decode(harp_packet.payload) + except ValueError as e: + raise ValueError(f"Failed to decode HoiPacket2: {e}") + + fragments = hoi_packet.data_fragments + if len(fragments) > 0 and fragments[0]["format"] == ParameterTypes.HcResult: + if fragments[0]["fragment_data"] != 0: + raise ValueError(f"Command failed with error code {fragments[0]['fragment_data']}") + + return + + async def send_command( + self, + command_id: int, + parameters: bytes, + harp_source: "Prep.HarpPacket.HarpAddress", + harp_destination: "Prep.HarpPacket.HarpAddress", + hoi_action: "Prep.HoiPacket2.Hoi2Action" = None, + timeout: Optional[float] = None, + ) -> bytes: + command = self._assemble_command( + command_id=command_id, + parameters=parameters, + harp_source=harp_source, + harp_destination=harp_destination, + hoi_action=hoi_action or Prep.HoiPacket2.Hoi2Action.CommandRequest, + ) + print("Sending command:", command.hex()) + self.socket.send(command) + + response = self.socket.recv(1024) + print("Received response:", response.hex()) + + self._decode_response(response) + + class IpPacket: + FIXED_FORMAT = "H B B H" # ushort, ubyte, ubyte, ushort + FIXED_SIZE = struct.calcsize(FIXED_FORMAT) + + class IpPacketOption: + class Option(IntEnum): + Reserved = 0 + IncompatibleVersion = 1 + UnsupportedOption = 2 + HcResultIpOption = 3 + + BASE_FORMAT = "BB" # Fixed fields: option (1 byte), length (1 byte) + BASE_SIZE = struct.calcsize(BASE_FORMAT) + + def __init__(self, option: Option, length: int, data: bytes = None): + self.option = option + self.length = length + self.data = data or b"" + + def encode(self) -> bytes: + """Encode the IpPacketOption into bytes.""" + if len(self.data) != self.length: + raise ValueError("data length does not match length") + return struct.pack(self.BASE_FORMAT, self.option.value, self.length) + self.data + + @classmethod + def decode(cls, data: bytes) -> "Prep.IpPacketOption": + """Decode the IpPacketOption from bytes.""" + if len(data) < cls.BASE_SIZE: + raise ValueError("Data too small to decode IpPacketOption") + + option, length = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) + data = data[cls.BASE_SIZE : cls.BASE_SIZE + length] if length > 0 else b"" + if len(data) != length: + raise ValueError("data length does not match length in header") + return cls(Prep.IpPacket.IpPacketOption.Option(option), length, data) + + def __repr__(self): + return f"IpPacketOption(option={self.option}, length={self.length}, data={self.data})" + + class TransportableProtocol(IntEnum): + None_ = 0 + Xml = 1 + Bz = 4 + Ml600 = 5 + Harp2 = 6 + Connection = 7 + Serial = 8 + Can = 9 + MultiSerial = 10 + Last = 11 + Invalid = 255 + + def __init__( + self, + protocol: TransportableProtocol, + version: Tuple[int, int], + options: Optional[List[IpPacketOption]], + payload: bytes, + ): + self.protocol = protocol + self.version = version + self.options = options + self.payload = payload + + @property + def size(self) -> int: + # exclude size field (ushort, 2 bytes) + return self.FIXED_SIZE + (self.options_length or 0) + len(self.payload or b"") - 2 + + @property + def options_length(self) -> int: + return sum(option.length for option in self.options) if self.options is not None else 0 + + @classmethod + def decode(cls, data: bytes) -> "Prep.IpPacket": + """Decode an IpPacket from raw bytes.""" + if len(data) < cls.FIXED_SIZE: + raise ValueError( + f"Data is too small to decode (expected at least {cls.FIXED_SIZE} bytes, got {len(data)})" + ) + + # Unpack the fixed fields + size, protocol, version_byte, options_length = struct.unpack( + cls.FIXED_FORMAT, data[: cls.FIXED_SIZE] + ) + version = (version_byte & 240) >> 4, version_byte & 15 + + # Decode options and payload + offset = cls.FIXED_SIZE + options = None + if options_length > 0: + if len(data) < offset + options_length: + raise ValueError("Data too small to contain options") + options = data[offset : offset + options_length] + offset += options_length + + payload = None + if offset < len(data): + payload = data[offset:] + + if not len(data) - 2 == size: + raise ValueError("Packet size does not match size field") + + return cls(Prep.IpPacket.TransportableProtocol(protocol), version, options, payload) + + def encode(self) -> bytes: + """Encode the IpPacket into bytes.""" + version_byte = (self.version[0] << 4) | self.version[1] + header = struct.pack( + self.FIXED_FORMAT, self.size, self.protocol, version_byte, self.options_length + ) + options = self.options or b"" + return header + options + (self.payload or b"") + + class HarpPacket: + class HarpAddress: + FORMAT = "3H" # 3 unsigned shorts + SIZE = struct.calcsize(FORMAT) + + def __init__(self, address: Tuple[int, int, int]): + self.address = address + + def encode(self): + return struct.pack(self.FORMAT, *self.address) + + @classmethod + def decode(cls, data): + return cls(struct.unpack(cls.FORMAT, data[: cls.SIZE])) + + def __str__(self): + return ".".join(hex(byte) for byte in self.address) + + def __eq__(self, other: "Prep.HarpPacket.HarpAddress") -> bool: + return self.address == other.address + + class Action: + FORMAT = "B" # 1 byte + SIZE = struct.calcsize(FORMAT) + + def __init__(self, reserved): + self.reserved = reserved + + @property + def response_required(self): + return (self.reserved & 16) >> 4 + + @property + def payload_description(self): + return self.reserved & 15 + + @staticmethod + def create(response_required, payload_description): + reserved = (response_required << 4) | payload_description + # TODO: why is this named reserved? + return Prep.HarpPacket.Action(reserved) + + def encode(self): + return struct.pack(self.FORMAT, self.reserved) + + @classmethod + def decode(cls, data): + (reserved,) = struct.unpack(cls.FORMAT, data[: cls.SIZE]) + return cls(reserved) + + def __eq__(self, other: "Prep.HarpPacket.Action") -> bool: + return self.reserved == other.reserved + + BASE_FORMAT = ( + HarpAddress.FORMAT * 2 + "BB" + "B" + Action.FORMAT + "H" + "H" + "B" + "B" + ) # source, destination, sequence_number, reserved_1, protocol, action, length, options_length, version, reserved_2 + BASE_SIZE = struct.calcsize(BASE_FORMAT) + + def __init__( + self, + source, + destination, + sequence_number, + reserved_1, + protocol, + action, + options: Optional[List["Prep.HarpPacketOption"]], + version, + reserved_2, + payload: bytes, + ): + self.source = source + self.destination = destination + self.sequence_number = sequence_number + self.reserved_1 = reserved_1 + self.protocol = protocol + self.action = action + self.options = options or [] + self.version = version + self.reserved_2 = reserved_2 + + # not part of the Hamilton implementation, but it is added ad-hoc. We just store it as an + # attribute, similar to IpPacket.payload. + self.payload = payload + + @property + def length(self) -> int: + return self.BASE_SIZE + self.options_length + len(self.payload) + + @property + def options_length(self) -> int: + return sum(option.length for option in self.options) + + def encode(self): + header = struct.pack( + self.BASE_FORMAT, + *self.source.address, + *self.destination.address, + self.sequence_number, + self.reserved_1, + self.protocol.value, + self.action.reserved, + self.length, + self.options_length, + self.version, + self.reserved_2, + ) + options = b"".join(option.encode() for option in self.options) + return header + options + self.payload + + @classmethod + def decode(cls, data): + if len(data) < cls.BASE_SIZE: + raise ValueError(f"Data too small to decode (expected at least {cls.BASE_SIZE} bytes)") + + unpacked_data = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) + source = Prep.HarpPacket.HarpAddress(unpacked_data[:3]) + destination = Prep.HarpPacket.HarpAddress(unpacked_data[3:6]) + sequence_number = unpacked_data[6] + reserved_1 = unpacked_data[7] + protocol = Prep.HarpPacket.HarpTransportableProtocol(unpacked_data[8]) + action = Prep.HarpPacket.Action(unpacked_data[9]) + length = unpacked_data[10] + options_length = unpacked_data[11] + version = unpacked_data[12] + reserved_2 = unpacked_data[13] + + offset = cls.BASE_SIZE + options = [] + for _ in range(options_length): + option = Prep.HarpPacket.HarpPacketOption.decode(data[offset:]) + options.append(option) + offset += Prep.HarpPacket.HarpPacketOption.BASE_SIZE + option.length + + payload = data[offset:] + if not cls.BASE_SIZE + options_length + len(payload) == length: + raise ValueError("Payload length does not match length") + + return cls( + source=source, + destination=destination, + sequence_number=sequence_number, + reserved_1=reserved_1, + protocol=protocol, + action=action, + options=options, + version=version, + reserved_2=reserved_2, + payload=payload, + ) + + class HarpTransportableProtocol(IntEnum): + Hoi2 = 2 + Registration2 = 3 + Lst = 4 + Undefined = 255 + + class PayloadDescription(IntEnum): + StatusRequest = 0 + StatusResponse = 1 + StatusException = 2 + CommandRequest = 3 + CommandResponse = 4 + CommandException = 5 + CommandAck = 6 + UpStreamSystemEvent = 7 + DownStreamSystemEvent = 8 + Event = 9 + InvalidActionResponse = 10 + StatusWarning = 11 + CommandWarning = 12 + + class ResponseRequired(IntEnum): + No = 0 + Yes = 1 + + class HarpPacketOption: + BASE_FORMAT = "BB" # option (1 byte), length (1 byte) + BASE_SIZE = struct.calcsize(BASE_FORMAT) + + class Option(IntEnum): + Reserved = 0 + RoutingError = 1 + IncompatibleVersion = 2 + UnsupportedOptions = 3 + + def __init__(self, option: Option, length: int, data=None): + self.option = option + self.length = length + self.data = data or b"" + + def encode(self): + return struct.pack(self.BASE_FORMAT, self.option, self.length) + self.data + + @classmethod + def decode(cls, data): + base_data = data[: cls.BASE_SIZE] + option, length = struct.unpack(cls.BASE_FORMAT, base_data) + data = data[cls.BASE_SIZE : cls.BASE_SIZE + length] if length > 0 else b"" + return cls(option, length, data) + + class HoiPacket2: + BASE_FORMAT = "B B H BB" # interface_id (1 byte), action (1 byte), action_id (2 bytes), version (1 byte), number_of_fragments (1 byte) + BASE_SIZE = struct.calcsize(BASE_FORMAT) + + def __init__( + self, + interface_id: int, + action: "Prep.HoiPacket2.Hoi2Action", + action_id: int, + version: int, + data_fragments: List[bytes], + ): + self.interface_id = interface_id + self.action = action + self.action_id = action_id + self.version = version + # for Hamilton, this is a list of `DataFragment`s. But, it is easier to just store the encoded bytes. + self.data_fragments = data_fragments + + @property + def number_of_fragments(self) -> int: + return len(self.data_fragments) + + def encode(self): + header = struct.pack( + self.BASE_FORMAT, + self.interface_id, + self.action.value if isinstance(self.action, Prep.HoiPacket2.Hoi2Action) else self.action, + self.action_id, + self.version, + self.number_of_fragments, + ) + return header + b"".join(self.data_fragments) + + @classmethod + def decode(cls, data): + if len(data) < cls.BASE_SIZE: + raise ValueError( + f"Data too small to decode HoiPacket2 (expected at least {cls.BASE_SIZE} bytes)" + ) + + unpacked = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) + interface_id, action, action_id, version, number_of_fragments = unpacked + + offset = cls.BASE_SIZE + fragments = [] + while offset < len(data): + fragment = parse_data_fragment(data[offset:]) + length = fragment["length"] + fragments.append(fragment) + offset += length + assert len(fragments) == number_of_fragments, "Number of fragments does not match header" + + return cls( + interface_id=interface_id, + action=Prep.HoiPacket2.Hoi2Action(action) + if action <= max(Prep.HoiPacket2.Hoi2Action) + else action, + action_id=action_id, + version=version, + data_fragments=fragments, + ) + + def __repr__(self): + return ( + f"HoiPacket2(interface_id={self.interface_id}, action={self.action}, " + f"action_id={self.action_id}, version={self.version}, " + f"number_of_fragments={self.number_of_fragments}, data_fragments={self.data_fragments})" + ) + + class BitField(IntEnum): + None_ = 0 + Padded = 1 + Unused2 = 2 + Unused3 = 4 + Unused4 = 8 + Unused5 = 65536 + Unused6 = Padded + Unused7 = Unused2 + + class Hoi2Action(IntEnum): + StatusRequest = 0 + StatusResponse = 1 + StatusException = 2 + CommandRequest = 3 + CommandResponse = 4 + CommandException = 5 + CommandAck = 6 + UpStreamSystemEvent = 7 + DownStreamSystemEvent = 8 + Event = 9 + InvalidActionResponse = 10 + StatusWarning = 11 + CommandWarning = 12 + + class Hoi2Eventaction_id(IntEnum): + Registration = 1 + Deregistration = 2 + Event = 3 + + class Hoi2EventAction(IntEnum): + EventRegisterDeregisterRequest = 1 + EventRegisterDeregisterResponse = 2 + EventNotification = 3 + + class DataFragment: + FORMAT = "I" # Example format for individual fragments + SIZE = struct.calcsize(FORMAT) + + def __init__(self, value): + self.value = value + + def encode(self): + return struct.pack(self.FORMAT, self.value) + + @classmethod + def decode(cls, data): + (value,) = struct.unpack(cls.FORMAT, data[: cls.SIZE]) + return cls(value) + + class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + class PressureMode(IntEnum): + OverPressure = 0 + UnderPressure = 1 + + class LLDStatus(IntEnum): + NotDetected = 0 + Detected = 1 + Disabled = 2 + + class ChannelType(IntEnum): + NoChannel = 0 + UnknownChannelType = 1 + Single1000uLChannel = 2 + MPH8x1000uLChannel = 3 + + class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + class ChannelAxis(IntEnum): + YAxis = 0 + ZAxis = 1 + SqueezeAxis = 2 + DispenserAxis = 3 + + class MPHChannelID(IntEnum): + MPHChannel1 = 1 + MPHChannel2 = 2 + MPHChannel3 = 3 + MPHChannel4 = 4 + MPHChannel5 = 5 + MPHChannel6 = 6 + MPHChannel7 = 7 + MPHChannel8 = 8 + + class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + class ZTravelMode(IntEnum): + ZLimitTraverse = 0 + AdjustableTraverse = 1 + CalculatedTraverse = 2 + TerrainFollow = 3 + + class XYTravelMode(IntEnum): + Direct = 0 + XFirst = 1 + YFirst = 2 + Path = 3 + + class VolumeType(IntEnum): + TransportAir = 0 + StopBack = 1 + Liquid = 2 + Blowout = 3 + InitialVolume = 4 + ErrorVolume = 5 + + # class TipTypes(IntEnum): + # UNKNOWN = 0 + # STANDARD = 1 + # FILTER = 2 + # NEEDLE = 3 + + @dataclass + class SeekParameters: + x_start: float # real 32 bit + y_start: float # real 32 bit + z_start: float # real 32 bit + distance: float # real 32 bit + expected_position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.x_start, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_start, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_start, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.distance, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.expected_position, ParameterTypes.Real32Bit) + return out + + @dataclass + class XYZCoord: + default_values: bool # bool + x_position: float # real 32 bit + y_position: float # real 32 bit + z_position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) + return out + + @dataclass + class XYCoord: + default_values: bool # bool + x_position: float # real 32 bit + y_position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + return out + + @dataclass + class ChannelYZMoveParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + y_position: float # real 32 bit + z_position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) + return out + + @dataclass + class GantryMoveXYZParameters: + default_values: bool # bool + gantry_x_position: float # real 32 bit + axis_parameters: list["Prep.ChannelYZMoveParameters"] # array of ChannelYZMoveParameters + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.gantry_x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.axis_parameters, ParameterTypes.StructureArray) + return out + + @dataclass + class PlateDimensions: + default_values: bool # bool + length: float # real 32 bit + width: float # real 32 bit + height: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.width, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.height, ParameterTypes.Real32Bit) + return out + + @dataclass + class TipDefinition: + default_values: bool # bool + id: int # byte (UInt8Bit) + volume: float # real 32 bit + length: float # real 32 bit + tip_type: "Prep.TipTypes" # enum + has_filter: bool # bool + is_needle: bool # bool + is_tool: bool # bool + label: str # string + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.id, ParameterTypes.UInt8Bit) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) + out += encode_data_fragment(self.has_filter, ParameterTypes.Bool) + out += encode_data_fragment(self.is_needle, ParameterTypes.Bool) + out += encode_data_fragment(self.is_tool, ParameterTypes.Bool) + out += encode_data_fragment(self.label, ParameterTypes.String) + return out + + @dataclass + class TipPickupParameters: + default_values: bool # bool + volume: float # real 32 bit + length: float # real 32 bit + tip_type: "Prep.TipTypes" # enum + has_filter: bool # bool + is_needle: bool # bool + is_tool: bool # bool + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) + out += encode_data_fragment(self.has_filter, ParameterTypes.Bool) + out += encode_data_fragment(self.is_needle, ParameterTypes.Bool) + out += encode_data_fragment(self.is_tool, ParameterTypes.Bool) + return out + + @dataclass + class AspirateParameters: + default_values: bool # bool + x_position: float # real 32 bit + y_position: float # real 32 bit + prewet_volume: float # real 32 bit + blowout_volume: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.prewet_volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.blowout_volume, ParameterTypes.Real32Bit) + return out + + @dataclass + class DispenseParameters: + default_values: bool # bool + x_position: float # real 32 bit + y_position: float # real 32 bit + stop_back_volume: float # real 32 bit + cutoff_speed: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.stop_back_volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.cutoff_speed, ParameterTypes.Real32Bit) + return out + + @dataclass + class CommonParameters: + default_values: bool # bool + empty: bool # bool + z_minimum: float # real 32 bit + z_final: float # real 32 bit + z_liquid_exit_speed: float # real 32 bit + liquid_volume: float # real 32 bit + liquid_speed: float # real 32 bit + transport_air_volume: float # real 32 bit + tube_radius: float # real 32 bit + cone_height: float # real 32 bit + cone_bottom_radius: float # real 32 bit + settling_time: float # real 32 bit + additional_probes: int # uint 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.empty, ParameterTypes.Bool) + out += encode_data_fragment(self.z_minimum, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_liquid_exit_speed, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.liquid_volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.liquid_speed, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.transport_air_volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.tube_radius, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.cone_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.cone_bottom_radius, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.settling_time, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.additional_probes, ParameterTypes.UInt32Bit) + return out + + @dataclass + class NoLldParameters: + default_values: bool # bool + z_fluid: float # real 32 bit + z_air: float # real 32 bit + bottom_search: bool # bool + z_bottom_search_offset: float # real 32 bit + z_bottom_offset: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.z_fluid, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_air, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.bottom_search, ParameterTypes.Bool) + out += encode_data_fragment(self.z_bottom_search_offset, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_bottom_offset, ParameterTypes.Real32Bit) + return out + + @dataclass + class LldParameters: + default_values: bool # bool + z_seek: float # real 32 bit + z_seek_speed: float # real 32 bit + z_submerge: float # real 32 bit + z_out_of_liquid: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_seek_speed, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_submerge, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_out_of_liquid, ParameterTypes.Real32Bit) + return out + + @dataclass + class CLldParameters: + default_values: bool # bool + sensitivity: "Prep.LldSensitivities" # enum + clot_check_enable: bool # bool + z_clot_check: float # real 32 bit + detect_mode: "Prep.DetectModes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.sensitivity, ParameterTypes.Enum) + out += encode_data_fragment(self.clot_check_enable, ParameterTypes.Bool) + out += encode_data_fragment(self.z_clot_check, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) + return out + + @dataclass + class PLldParameters: + default_values: bool # bool + sensitivity: "Prep.LldSensitivities" # enum + dispenser_seek_speed: float # real 32 bit + lld_height_difference: float # real 32 bit + detect_mode: "Prep.DetectModes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.sensitivity, ParameterTypes.Enum) + out += encode_data_fragment(self.dispenser_seek_speed, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.lld_height_difference, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) + return out + + @dataclass + class TadmReturnParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + entries: int # uint 32 bit + error: bool # bool + data: list[int] # array of short (16-bit signed) + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.entries, ParameterTypes.UInt32Bit) + out += encode_data_fragment(self.error, ParameterTypes.Bool) + out += encode_data_fragment(self.data, ParameterTypes.Int16Array) + return out + + @dataclass + class TadmParameters: + default_values: bool # bool + limit_curve_index: int # ushort + recording_mode: "Prep.TadmRecordingModes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.limit_curve_index, ParameterTypes.UInt16Bit) + out += encode_data_fragment(self.recording_mode, ParameterTypes.Enum) + return out + + @classmethod + def default(cls) -> "Prep.TadmParameters": + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=Prep.TadmRecordingModes.Errors, + ) + + @dataclass + class AspirateMonitoringParameters: + default_values: bool # bool + c_lld_enable: bool # bool + p_lld_enable: bool # bool + minimum_differential: int # ushort + maximum_differential: int # ushort + clot_threshold: int # ushort + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.c_lld_enable, ParameterTypes.Bool) + out += encode_data_fragment(self.p_lld_enable, ParameterTypes.Bool) + out += encode_data_fragment(self.minimum_differential, ParameterTypes.UInt16Bit) + out += encode_data_fragment(self.maximum_differential, ParameterTypes.UInt16Bit) + out += encode_data_fragment(self.clot_threshold, ParameterTypes.UInt16Bit) + return out + + @classmethod + def default(cls) -> "Prep.AspirateMonitoringParameters": + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + @dataclass + class MixParameters: + default_values: bool # bool + z_offset: float # real 32 bit + volume: float # real 32 bit + cycles: int # byte (UInt8Bit) + speed: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.z_offset, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.cycles, ParameterTypes.UInt8Bit, padded=True) + out += encode_data_fragment(self.speed, ParameterTypes.Real32Bit) + return out + + @classmethod + def default(cls) -> "Prep.MixParameters": + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + @dataclass + class AdcParameters: + default_values: bool # bool + errors: bool # bool + maximum_volume: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.errors, ParameterTypes.Bool) + out += encode_data_fragment(self.maximum_volume, ParameterTypes.Real32Bit) + return out + + @classmethod + def default(cls) -> "Prep.AdcParameters": + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + @dataclass + class ChannelXYZPositionParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + position_x: float # real 32 bit + position_y: float # real 32 bit + position_z: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.position_x, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.position_z, ParameterTypes.Real32Bit) + return out + + @dataclass + class PressureReturnParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + pressure: int # ushort + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.pressure, ParameterTypes.UInt16Bit) + return out + + @dataclass + class LiquidHeightReturnParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + c_lld_detected: bool # bool + c_lld_liquid_height: float # real 32 bit + p_lld_detected: bool # bool + p_lld_liquid_height: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.c_lld_detected, ParameterTypes.Bool) + out += encode_data_fragment(self.c_lld_liquid_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.p_lld_detected, ParameterTypes.Bool) + out += encode_data_fragment(self.p_lld_liquid_height, ParameterTypes.Real32Bit) + return out + + @dataclass + class DispenserVolumeReturnParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + volume: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + return out + + @dataclass + class PotentiometerParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + gain: int # byte (UInt8Bit) + offset: int # byte (UInt8Bit) + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.gain, ParameterTypes.UInt8Bit) + out += encode_data_fragment(self.offset, ParameterTypes.UInt8Bit) + return out + + @dataclass + class YLLDSeekParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + start_position_x: float # real 32 bit + start_position_y: float # real 32 bit + start_position_z: float # real 32 bit + seek_position_y: float # real 32 bit + seek_velocity_y: float # real 32 bit + lld_sensitivity: "Prep.LldSensitivities" # enum + detect_mode: "Prep.DetectModes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.start_position_x, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.start_position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.start_position_z, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_velocity_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.lld_sensitivity, ParameterTypes.Enum) + out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) + return out + + @dataclass + class ChannelSeekParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + seek_position_x: float # real 32 bit + seek_position_y: float # real 32 bit + seek_height: float # real 32 bit + min_seek_height: float # real 32 bit + final_position_z: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.seek_position_x, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.min_seek_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.final_position_z, ParameterTypes.Real32Bit) + return out + + @dataclass + class LLDChannelSeekParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + seek_position_x: float # real 32 bit + seek_position_y: float # real 32 bit + seek_velocity_z: float # real 32 bit + seek_height: float # real 32 bit + min_seek_height: float # real 32 bit + final_position_z: float # real 32 bit + lld_sensitivity: "Prep.LldSensitivities" # enum + detect_mode: "Prep.DetectModes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.seek_position_x, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_velocity_z, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.min_seek_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.final_position_z, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.lld_sensitivity, ParameterTypes.Enum) + out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) + return out + + @dataclass + class SeekResultParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + detected: bool # bool + position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.detected, ParameterTypes.Bool) + out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) + return out + + @dataclass + class ChannelCounterParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + tip_pickup_counter: int # uint 32 bit + tip_eject_counter: int # uint 32 bit + aspirate_counter: int # uint 32 bit + dispense_counter: int # uint 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.tip_pickup_counter, ParameterTypes.UInt32Bit) + out += encode_data_fragment(self.tip_eject_counter, ParameterTypes.UInt32Bit) + out += encode_data_fragment(self.aspirate_counter, ParameterTypes.UInt32Bit) + out += encode_data_fragment(self.dispense_counter, ParameterTypes.UInt32Bit) + return out + + @dataclass + class ChannelCalibrationParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + dispenser_return_steps: int # uint 32 bit + squeeze_position: float # real 32 bit + z_touchoff: float # real 32 bit + z_tip_height: float # real 32 bit + pressure_monitoring_shift: int # uint 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.dispenser_return_steps, ParameterTypes.UInt32Bit) + out += encode_data_fragment(self.squeeze_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_touchoff, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_tip_height, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.pressure_monitoring_shift, ParameterTypes.UInt32Bit) + return out + + @dataclass + class LeakCheckSimpleParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + time: float # real 32 bit + high_pressure: bool # bool + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.time, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.high_pressure, ParameterTypes.Bool) + return out + + @dataclass + class LeakCheckParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + start_position_x: float # real 32 bit + start_position_y: float # real 32 bit + start_position_z: float # real 32 bit + seek_distance_y: float # real 32 bit + pre_load_distance_y: float # real 32 bit + final_z: float # real 32 bit + tip_definition_id: int # byte (UInt8Bit) + test_time: float # real 32 bit + high_pressure: bool # bool + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.start_position_x, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.start_position_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.start_position_z, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.seek_distance_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.pre_load_distance_y, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.final_z, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.tip_definition_id, ParameterTypes.UInt8Bit) + out += encode_data_fragment(self.test_time, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.high_pressure, ParameterTypes.Bool) + return out + + @dataclass + class ChannelDriveStatus: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + y_axis_drive_status: "Prep.DriveStatus" # struct + z_axis_drive_status: "Prep.DriveStatus" # struct + dispenser_drive_status: "Prep.DriveStatus" # struct + squeeze_drive_status: "Prep.DriveStatus" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.y_axis_drive_status, ParameterTypes.Structure) + out += encode_data_fragment(self.z_axis_drive_status, ParameterTypes.Structure) + out += encode_data_fragment(self.dispenser_drive_status, ParameterTypes.Structure) + out += encode_data_fragment(self.squeeze_drive_status, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersNoLldAndMonitoring: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersNoLldAndTadm: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersLldAndMonitoring: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + p_lld: "Prep.PLldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct + adc: "Prep.AdcParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersLldAndTadm: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + p_lld: "Prep.PLldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + tadm: "Prep.TadmParameters" # struct + adc: "Prep.AdcParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + return out + + @dataclass + class DispenseParametersNoLld: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + dispense: "Prep.DispenseParameters" # struct + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.dispense, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + @dataclass + class DispenseParametersLld: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + dispense: "Prep.DispenseParameters" # struct + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.dispense, ParameterTypes.Structure) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + @dataclass + class DropTipParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + y_position: float # real 32 bit + z_seek: float # real 32 bit + z_tip: float # real 32 bit + z_final: float # real 32 bit + z_seek_speed: float # real 32 bit + drop_type: "Prep.TipDropType" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_tip, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_seek_speed, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.drop_type, ParameterTypes.Enum) + return out + + @dataclass + class InitTipDropParameters: + default_values: bool # bool + x_position: float # real 32 bit + rolloff_distance: float # real 32 bit + channel_parameters: list["Prep.DropTipParameters"] # array of DropTipParameters + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.rolloff_distance, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.channel_parameters, ParameterTypes.StructureArray) + return out + + @dataclass + class DispenseInitToWasteParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + x_position: float # real 32 bit + y_position: float # real 32 bit + z_position: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) + return out + + @dataclass + class MoveAxisAbsoluteParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + axis: "Prep.ChannelAxis" # enum + position: float # real 32 bit + delay: int # uint 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.axis, ParameterTypes.Enum) + out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.delay, ParameterTypes.UInt32Bit) + return out + + @dataclass + class MoveAxisRelativeParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + axis: "Prep.ChannelAxis" # enum + distance: float # real 32 bit + delay: int # uint 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.axis, ParameterTypes.Enum) + out += encode_data_fragment(self.distance, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.delay, ParameterTypes.UInt32Bit) + return out + + @dataclass + class LimitCurveEntry: + default_values: bool # bool + sample: int # ushort (UInt16Bit) + pressure: int # short (Int16) + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.sample, ParameterTypes.UInt16Bit) + out += encode_data_fragment(self.pressure, ParameterTypes.Int16Bit) + return out + + @dataclass + class TipPositionParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + x_position: float # real 32 bit + y_position: float # real 32 bit + z_position: float # real 32 bit + z_seek: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) + return out + + @dataclass + class TipDropParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + x_position: float # real 32 bit + y_position: float # real 32 bit + z_position: float # real 32 bit + z_seek: float # real 32 bit + drop_type: "Prep.TipDropType" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.drop_type, ParameterTypes.Enum) + return out + + @dataclass + class TipHeightCalibrationParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + x_position: float # real 32 bit + y_position: float # real 32 bit + z_start: float # real 32 bit + z_stop: float # real 32 bit + z_final: float # real 32 bit + volume: float # real 32 bit + tip_type: "Prep.TipTypes" # enum + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_start, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_stop, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) + return out + + @dataclass + class DispenserVolumeEntry: + default_values: bool # bool + type: "Prep.VolumeType" # enum + volume: float # real 32 bit + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.type, ParameterTypes.Enum) + out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) + return out + + @dataclass + class DispenserVolumeStackReturnParameters: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + total_volume: float # real 32 bit + volumes: list["Prep.DispenserVolumeEntry"] # array of DispenserVolumeEntry + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.total_volume, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.volumes, ParameterTypes.StructureArray) + return out + + @dataclass + class AspirateParametersNoLldAndMonitoring2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersNoLldAndTadm2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersLldAndMonitoring2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + p_lld: "Prep.PLldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct + adc: "Prep.AdcParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + return out + + @dataclass + class AspirateParametersLldAndTadm2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + aspirate: "Prep.AspirateParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + p_lld: "Prep.PLldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + tadm: "Prep.TadmParameters" # struct + adc: "Prep.AdcParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + return out + + @dataclass + class DispenseParametersNoLld2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + dispense: "Prep.DispenseParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + no_lld: "Prep.NoLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.dispense, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + @dataclass + class DispenseParametersLld2: + default_values: bool # bool + channel: "Prep.ChannelIndex" # enum + dispense: "Prep.DispenseParameters" # struct + container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor + common: "Prep.CommonParameters" # struct + lld: "Prep.LldParameters" # struct + c_lld: "Prep.CLldParameters" # struct + mix: "Prep.MixParameters" # struct + adc: "Prep.AdcParameters" # struct + tadm: "Prep.TadmParameters" # struct + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.default_values, ParameterTypes.Bool) + out += encode_data_fragment(self.channel, ParameterTypes.Enum) + out += encode_data_fragment(self.dispense, ParameterTypes.Structure) + out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) + out += encode_data_fragment(self.common, ParameterTypes.Structure) + out += encode_data_fragment(self.lld, ParameterTypes.Structure) + out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) + out += encode_data_fragment(self.mix, ParameterTypes.Structure) + out += encode_data_fragment(self.adc, ParameterTypes.Structure) + out += encode_data_fragment(self.tadm, ParameterTypes.Structure) + return out + + class Error(IntEnum): + ChannelsBusy = 3585 + InvalidChannelIndex = 3586 + SiteNotDefined = 3587 + ChannelPowerRemoved = 3588 + HeadlessChannel = 3589 + CoordinatorProxyTimeout = 3590 + CalibrationInProgress = 3591 + + # User-discovered + # TipNotFound = 3848 + + class HcResult(IntEnum): + Success = 0x0000 + GenericError = 0x0001 + GenericNotReady = 0x0002 + GenericNullParameter = 0x0003 + GenericCalledByInitHandler = 0x0004 + GenericInvalidData = 0x0005 + GenericOutOfMemory = 0x0006 + GenericWriteFault = 0x0007 + GenericReadFault = 0x0008 + GenericBufferOverflow = 0x0009 + GenericNotInitialized = 0x000A + GenericAlreadyInitialized = 0x000B + GenericWaitAborted = 0x000C + GenericTimeOut = 0x000D + GenericMissingCallBack = 0x000E + GenericInvalidHandle = 0x000F + GenericNotSupported = 0x0010 + GenericInvalidParameter = 0x0011 + GenericNotImplemented = 0x0012 + GenericBadCrc = 0x0013 + GenericFlashNotBlank = 0x0014 + GenericMultipleErrorsReported = 0x0015 + GenericCoordinatedCommandTimeout = 0x0016 + GenericAccessDenied = 0x0017 + GenericBusy = 0x0019 + GenericMethodObsolete = 0x001A + GenericNotConfigured = 0x001B + GenericNotCalibrated = 0x001C + GenericOptionalFunctionalityNotPresent = 0x001D + GenericResumeFromInvalidState = 0x001E + GenericAbortFromInvalidState = 0x001F + GenericActionAborted = 0x0020 + GenericPauseFromInvalidState = 0x0021 + GenericPaused = 0x0022 + GenericSuspended = 0x0023 + GenericExitSuspendFromInvalidState = 0x0024 + KernelMutexTimeout = 0x0101 + KernelSemaphoreTimeout = 0x0102 + KernelEventTimeout = 0x0103 + KernelNoMutex = 0x0104 + KernelMutexNotOwned = 0x0105 + KernelNoWaitingTask = 0x0106 + KernelInvalidTask = 0x0107 + KernelNoTaskControlBlock = 0x0108 + NetworkUndefinedProtocol = 0x0201 + NetworkNoDestination = 0x0202 + NetworkRegistrationError = 0x0203 + NetworkNotRegistered = 0x0204 + NetworkBusy = 0x0205 + NetworkInvalidDispatchID = 0x0206 + NetworkInvalidMessage = 0x0207 + NetworkUnsupportedParameter = 0x0208 + NetworkCommandCompleteNotValid = 0x0209 + NetworkInvalidMessageParameter = ( + 0x020A # went command id is wrong, or when parameters don't match the command + ) + NetworkIncompatibleProtocolVersion = 0x020B + NetworkInvalidNodeId = 0x020C + NetworkInvalidModuleId = 0x020D + NetworkInvalidInterfaceId = 0x020E + NetworkInvalidAction = 0x020F + NetworkProxySendAttemptFailed = 0x0210 + NetworkRegistrationFailedDuplicateAddress = 0x0211 + NetworkUnableToProperlyFillOutResults = 0x0212 + NetworkDuplicateEventRegistration = 0x0213 + NetworkEventRegistrationExceedsMaximumAllowedSubscribers = 0x0214 + NetworkMaximumNodeToNodeEventRegistrationsExceeded = 0x0215 + NetworkMaximumNodeToNodeEventHandlerRegistrationsExceeded = 0x0216 + NetworkUnsupportedHarpPayloadProtocol = 0x0217 + NetworkUnableToSubscribeInvalidEvent = 0x0218 + NetworkGlobalObjectDefinedButNotInstantiated = 0x0219 + NetworkNodeGlobalObjectDefinedButNotInstantiated = 0x021A + NetworkProxyRequestValidationFailed = 0x021B + XPortSlOsPortNotInstalled = 0x0301 + XPortSlIpTaskPriorityNotSet = 0x0302 + XPortSlTimerTaskPriorityNotSet = 0x0303 + XPortSlDriverNotSet = 0x0304 + XPortSlIpAddressNotSet = 0x0305 + XPortSlNetMaskNotSet = 0x0306 + XPortSlCmxInitFailure = 0x0307 + XPortSlMacAddressNotSet = 0x0308 + XPortSlHostNameTooShort = 0x0309 + XPortSlNostNameTooLong = 0x030A + XPortSlHostNameInvalidChars = 0x030B + XPortNxpLpc2xxxCanInvalidChannel = 0x0320 + XPortNxpLpc2xxxCanInvalidGroup = 0x0321 + XPortNxpLpc2xxxCanBitRate = 0x0322 + XPortNxpLpc2xxxCanRxInterruptInstall = 0x0323 + XPortNxpLpc2xxxCanRxInterrupRemove = 0x0324 + XPortNxpLpc2xxxCanTxInterruptInstall = 0x0325 + XPortNxpLpc2xxxCanTxInterrupRemove = 0x0326 + XPortNxpLpc2xxxCanTxInvalidLength = 0x0327 + XPortNxpLpc2xxxCanTxBusy = 0x0328 + XPortArcNetAlreadyConfigured = 0x0329 + XPortArcNetNotConfigured = 0x032A + XPortArcNetInterruptInstallFailed = 0x032B + XPortArcNetTxNoAck = 0x032C + XPortArcNetDiagnosticTestFailed = 0x032D + XPortArcNetNodeIdTestFailed = 0x032E + XPortArcNetInvalidNodeId = 0x032F + XPortArcNetTxNotAvailable = 0x0330 + XPortArcNetInvalidDataRate = 0x0331 + XPortArcNetInvalidPacketLength = 0x0332 + XPortArcNetSingleNodeNetwork = 0x0333 + XPortArcNetNoResponseToFbe = 0x0334 + XPortProtocolMismatch = 0x0341 + XPortPacketRouterNotRegistered = 0x0342 + XPortCouldNotStartPacketRouterRxThread = 0x0343 + XPortPacketRouterAlreadyRegistered = 0x0344 + XPortNoPacketToProcess = 0x0345 + XPortWireProtocolNotRegistered = 0x0346 + XPortWireProtocolAlreadyRegistered = 0x0347 + XPortWireProtocolRegistrationSpaceFull = 0x0348 + XPortPayloadProtocolNotRegistered = 0x0349 + XPortPayloadProtocolAlreadyRegsitered = 0x034A + XPortPayloadRegistrationSpaceFull = 0x034B + XPortAddressNotSet = 0x034C + XPortAttemptToSendToSelf = 0x034D + XPortTxTimeout = 0x034E + XPortRxDuplicateFrame = 0x034F + XPortCanWp0VersionConflict = 0x0360 + XPortCanExcessivePacketSize = 0x0361 + XPortCanWp0AckHasNoMatchingPacket = 0x0362 + XPortCanWp0WrapperOnlyOneAddressSupported = 0x0363 + XPortCanWp0ErrorStartRefused = 0x0364 + XPortCanWp0ErrorBufferOverrun = 0x0365 + XPortCanWp0InvalidFrame = 0x0366 + XPortCanWp0StrayDataFrame = 0x0367 + XPortCanWp0ShortMessage = 0x0368 + XPortCanWp0LongMessage = 0x0369 + XPortCanWp0UnknownError = 0x036A + XPortCanWp0NoResponseFromDestination = 0x036B + XPortCanWp0SendError = 0x036C + XPortCanWbzUnknownFrame = 0x036D + XPortCanWbzUnsolicitedRemoteFrame = 0x036E + XPortCanWbzUnsolicitedDataFrame = 0x036F + XPortCanWbzWrapperOnlyOneAddressSupported = 0x0370 + XPortCanWp0LastMessageFailed = 0x0371 + XPortIpStackConfigurationFailure = 0x0380 + XPortIpStackNotConfigured = 0x0381 + XPortSocketCreationFailure = 0x0382 + XPortSocketConfigFailure = 0x0383 + XPortSocketBindFailure = 0x0384 + XPortIpTaskAlreadyStarted = 0x0385 + XPortIpTaskNotStarted = 0x0386 + XPortTcpListenFailure = 0x0387 + XPortTcpClientAlreadyConnected = 0x0388 + XPortTcpClientNotConnected = 0x0389 + XPortTcpConnectionFailure = 0x038A + XPortTcpCloseFailure = 0x038B + XPortTcpSendError = 0x038C + XPortUdpSendError = 0x038D + XPortMalformedDiscoveryRequest = 0x038E + XPortIpDhcpFailed = 0x038F + XPortIpStaticAddressConfigFailed = 0x0390 + XPortArcNetBufferOverrun = 0x03A0 + XPortArcNetVersionConflict = 0x03A1 + XPortArcNetInvalidFrameType = 0x03A2 + XPortArcNetInvalidFrame = 0x03A3 + XPortArcNetUnknownError = 0x03A4 + XPortArcNetAckHasNoMatchingPacket = 0x03A5 + XPortArcNetInvalidMessageSize = 0x03A6 + XPortArcNetLastMessageFailed = 0x03A7 + XPortArcNetWp0RefusedSyn = 0x03A8 + XPortArcNetWp0MessageTooShort = 0x03A9 + XPortArcNetWp0MessageTooLong = 0x03AA + XPortArcNetWp0InvalidSequenceNumber = 0x03AB + XPortArcNetWp0NoResponseFromDestination = 0x03AC + XPortRS232PppTimeout = 0x03C0 + ComLinkReferToInnerException = 0x0400 + ComLinkNotConnected = 0x0401 + ComLinkTcpConnectionFailed = 0x0402 + ComLinkFailedToCloseConnectionProperly = 0x0403 + ComLinkInvalidProtocolVersion = 0x0404 + ComLinkUnsupportedOptionsDetectedByServer = 0x0405 + ComLinkNodeIdNegotiationFailure = 0x0406 + ComLinkConnectionIntentError = 0x0407 + ComLinkUnableToConfigureKeepAlive = 0x0408 + ComLinkFailedToSendConnectionPacket = 0x0409 + ComLinkInvalidRegistrationAction = 0x040A + ComLinkUnexpectedRequestedHarpAddressReturned = 0x040B + ComLinkHarpAddressRegistrationFailed = 0x040C + ComLinkHarpAddressDeregistrationFailed = 0x040D + ComLinkIdentificationNotImplemented = 0x040E + ComLinkIdentificationNotSupported = 0x040F + ComLinkFailedToSendIdentificationRequest = 0x0410 + ComLinkNoResponseFromInstrumentRegistrationServer = 0x0411 + ComLinkNoRootObjectFound = 0x0412 + ComLinkEthernetObjectNotFound = 0x0413 + ComLinkMethodNotFound = 0x0414 + ComLinkProtocolActionConversionFailed = 0x0415 + ComLinkTimeout = 0x0416 + ComLinkUnableToSendOrReceive = 0x0417 + ComLinkTransportTransportableIntroductionFailure = 0x0418 + ComLinkHarpHarpableIntroductionFailure = 0x0419 + ComLinkDownloadException = 0x041A + ComLinkSizeOfReturnParametersNotValid = 0x041B + ComLinkRestrictedMethod = 0x041C + ComLinkInvalidNumberOfStructureParametersFromNetworkLayer = 0x041D + ComLinkInvalidTypeInStructureFromNetworkLayer = 0x041E + ComLinkRs232ConnectionFailed = 0x041F + ComLinkRs232InvalidPort = 0x0420 + ComLinkLoggingCannotBeConfiguredWhileConnectedOrConnecting = 0x0421 + ComLinkThreadAbortExceptionDetected = 0x0422 + ComLinkUnableToSend = 0x0423 + ComLinkUnableToReceive = 0x0424 + ComLinkConnectionRequiredToProceed = 0x0425 + ComLinkTooMuchDataToSend = 0x0426 + ComLinkCanConfigurationFailure = 0x0427 + ComLinkUnableToRetrieveListOfModules = 0x0428 + ComLinkTcpConnectionFailedConnectionRefused = 0x0429 + ComLinkTcpConnectionFailedHostUnreachable = 0x042A + ComLinkTcpConnectionFailedHostNotFound = 0x042B + ComLinkTcpConnectionFailedTimedOut = 0x042C + ComLinkTcpConnectionFailedIsConnected = 0x042D + ComLinkConnectionClosedWithOutstandingRequest = 0x042E + ComLinkNotConfigured = 0x042F + ComLinkRs232MultiFailedToConnect = 0x0430 + ComLinkAttemptToCallNonStatusRequestMethodWithMonitorConnection = 0x0431 + ComLinkPauseResumeFunctionalityNotSupported = 0x0432 + ComLinkFailedToCreateDeviceHandleForUsbDevice = 0x0433 + ComLinkUsbDeviceNotAvailable = 0x0434 + ComLinkUsbConnectionFailed = 0x0435 + ComLinkUsbConnectionLost = 0x0436 + ComLinkBonaduzError = 0x0437 + ComLinkUsbMultiFailedToConnect = 0x0438 + GenericMultipleWarningsReported = 0x8018 + + class TipTypes(IntEnum): + None_ = 0 # Use None_ since None is a reserved keyword in Python + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + class LldSensitivities(IntEnum): + Low = 0 + MediumLow = 1 + MediumHigh = 2 + High = 3 + Tool = 4 + Waste = 5 + + class DetectModes(IntEnum): + Any = 0 + Primary = 1 + Secondary = 2 + All = 3 + + class YAcceleration(IntEnum): + YLowestAcceleration = 1 + YLowAcceleration = 2 + YMediumAcceleration = 3 + YDefaultAcceleration = 4 + + @dataclass + class DriveStatus: + initialized: bool + position: float + encoder_position: float + in_home_sensor: bool + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.initialized, ParameterTypes.Bool) + out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.encoder_position, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.in_home_sensor, ParameterTypes.Bool) + return out + + @dataclass + class SegmentDescriptor: + area_top: float + area_bottom: float + height: float + + def encode(self) -> bytes: + out = b"" + out += encode_data_fragment(self.area_top, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.area_bottom, ParameterTypes.Real32Bit) + out += encode_data_fragment(self.height, ParameterTypes.Real32Bit) + return out + + # Liquid handler backend commands + + @property + def num_channels(self) -> int: + return 2 + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + final_z: float = 123.87, + timeout: Optional[float] = None, + ): + tip_parameters = [] + + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported for now" + + indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} + for channel_idx in range(2): + if channel_idx in indexed_ops: + op = indexed_ops[channel_idx] + loc = op.resource.get_absolute_location("c", "c", "t") + z = loc.z + op.resource.get_tip().total_tip_length + + tip_parameters.append( + Prep.TipPositionParameters( + default_values=False, + channel={ + 0: Prep.ChannelIndex.RearChannel, + 1: Prep.ChannelIndex.FrontChannel, + }[channel_idx], + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z + 12, # ? + ) + ) + + seek_speed = 15.0 + + assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip" + tip = ops[0].tip + tip_definition = Prep.TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=Prep.TipTypes.StandardVolume, # ? + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + enable_tadm = False + dispenser_volume = 0.0 + dispenser_speed = 250.0 + + return await self.send_command( + command_id=9, + parameters=[ + (tip_parameters, ParameterTypes.StructureArray), + (final_z, ParameterTypes.Real32Bit), + (seek_speed, ParameterTypes.Real32Bit), + (tip_definition, ParameterTypes.Structure), + (enable_tadm, ParameterTypes.Bool), + (dispenser_volume, ParameterTypes.Real32Bit), + (dispenser_speed, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + final_z: float = 123.87, + seek_speed: float = 10.0, + tip_roll_off_distance: float = 0.0, + timeout: Optional[float] = None, + ): + """Drop tips from the specified resource.""" + + tip_parameters = [] + + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported for now" + + indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} + + for channel_idx in range(2): + if channel_idx in indexed_ops: + op = indexed_ops[channel_idx] + loc = op.resource.get_absolute_location("c", "c", "t") + z = loc.z + op.resource.get_tip().total_tip_length + + tip_parameters.append( + Prep.TipDropParameters( + default_values=False, + channel={ + 0: Prep.ChannelIndex.RearChannel, + 1: Prep.ChannelIndex.FrontChannel, + }[channel_idx], + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z + 12, + drop_type=Prep.TipDropType.FixedHeight, + ) + ) + + return await self.send_command( + command_id=12, + parameters=[ + (tip_parameters, ParameterTypes.StructureArray), + (final_z, ParameterTypes.Real32Bit), + (seek_speed, ParameterTypes.Real32Bit), + (tip_roll_off_distance, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + z_final: float = 96.97, + timeout: Optional[float] = None, + ): + """Aspirate liquid from the specified resource using pip.""" + + aspirate_parameters = [] + + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported for now" + + indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} + for channel_idx in range(2): + if channel_idx in indexed_ops: + channel = { + 0: Prep.ChannelIndex.RearChannel, + 1: Prep.ChannelIndex.FrontChannel, + }[channel_idx] + + op = indexed_ops[channel_idx] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + + assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round" + radius = op.resource.get_size_x() / 2 + + aspirate_parameters.append( + Prep.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=channel, + aspirate=Prep.AspirateParameters( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=0.0, + blowout_volume=op.blow_out_air_volume or 0, + ), + common=Prep.CommonParameters( + default_values=False, + empty=True, + z_minimum=-5.03, # ? + z_final=z_final, + z_liquid_exit_speed=2.0, # ? + liquid_volume=op.volume, + liquid_speed=op.flow_rate or 100, # ? + transport_air_volume=0, # op.transport_air_volume, + tube_radius=radius, + cone_height=0.0, # TODO: + cone_bottom_radius=0.0, + settling_time=1.0, + additional_probes=0, + ), + no_lld=Prep.NoLldParameters( + default_values=False, + z_fluid=94.97, # ? + z_air=96.97, # ? + bottom_search=False, + z_bottom_search_offset=2.0, + z_bottom_offset=0.0, + ), + mix=Prep.MixParameters.default(), + adc=Prep.AdcParameters.default(), + aspirate_monitoring=Prep.AspirateMonitoringParameters.default(), + ) + ) + + return await self.send_command( + command_id=1, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + final_z: float = 96.97, + timeout: Optional[float] = None, + ): + """Dispense liquid from the specified resource using pip.""" + + dispense_parameters = [] + + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported for now" + + indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} + for channel_idx in range(2): + if channel_idx in indexed_ops: + op = indexed_ops[channel_idx] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + + assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round" + radius = op.resource.get_size_x() / 2 + + dispense_parameters.append( + Prep.DispenseParametersNoLld( + default_values=False, + channel={ + 0: Prep.ChannelIndex.RearChannel, + 1: Prep.ChannelIndex.FrontChannel, + }[channel_idx], + dispense=Prep.DispenseParameters( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=0.0, # ? + cutoff_speed=100.0, # ? + ), + common=Prep.CommonParameters( + default_values=False, + empty=True, # TODO + z_minimum=-5.03, # ? + z_final=final_z, + z_liquid_exit_speed=2.0, # ? + liquid_volume=op.volume, + liquid_speed=op.flow_rate or 100, + transport_air_volume=0, # op.transport_air_volume, + tube_radius=radius, + cone_height=0.0, # TODO + cone_bottom_radius=0, # TODO + settling_time=0.0, # TODO + additional_probes=0, # ? + ), + no_lld=Prep.NoLldParameters( + default_values=False, + z_fluid=94.97, # ? + z_air=99.08, # ? + bottom_search=False, + z_bottom_search_offset=2.0, + z_bottom_offset=0.0, + ), + mix=Prep.MixParameters.default(), + tadm=Prep.TadmParameters.default(), + adc=Prep.AdcParameters.default(), + ) + ) + + return await self.send_command( + command_id=5, + parameters=[ + (dispense_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("This operation is not supported on the Prep") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("This operation is not supported on the Prep") + + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): + raise NotImplementedError("This operation is not supported on the Prep") + + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): + raise NotImplementedError("This operation is not supported on the Prep") + + async def pick_up_resource(self, pickup: ResourcePickup): + raise NotImplementedError("This operation is not supported yet") + + async def move_picked_up_resource(self, move: ResourceMove): + raise NotImplementedError("This operation is not supported yet") + + async def drop_resource(self, drop: ResourceDrop): + raise NotImplementedError("This operation is not supported yet") + + # Firmware commands + + async def aspirate_tadm( + self, + aspirate_parameters: List["AspirateParametersNoLldAndTadm"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=2, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_lld( + self, + aspirate_parameters: List["AspirateParametersLldAndMonitoring"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=3, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_lld_tadm( + self, + aspirate_parameters: List["AspirateParametersLldAndTadm"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=4, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def dispense_lld( + self, + dispense_parameters: List["DispenseParametersLld"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=6, + parameters=[ + (dispense_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def dispense_initialize_to_waste( + self, + waste_parameters: List["DispenseInitToWasteParameters"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=7, + parameters=[ + (waste_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_tips_by_id( + self, + tip_parameters: List["TipPositionParameters"], + final_z: float, + seek_speed: float, + tip_definition_id: int, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=8, + parameters=[ + (tip_parameters, ParameterTypes.StructureArray), + (final_z, ParameterTypes.Real32Bit), + (seek_speed, ParameterTypes.Real32Bit), + (tip_definition_id, ParameterTypes.UInt8Bit), + (enable_tadm, ParameterTypes.Bool), + (dispenser_volume, ParameterTypes.Real32Bit), + (dispenser_speed, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_needles_by_id( + self, + tip_parameters: List["TipPositionParameters"], + final_z: float, + seek_speed: float, + tip_definition_id: int, + blowout_offset: float = 4.0, + blowout_speed: float = 0.0, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=10, + parameters=[ + (tip_parameters, ParameterTypes.StructureArray), + (final_z, ParameterTypes.Real32Bit), + (seek_speed, ParameterTypes.Real32Bit), + (tip_definition_id, ParameterTypes.UInt8Bit), + (blowout_offset, ParameterTypes.Real32Bit), + (blowout_speed, ParameterTypes.Real32Bit), + (enable_tadm, ParameterTypes.Bool), + (dispenser_volume, ParameterTypes.Real32Bit), + (dispenser_speed, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_needles( + self, + tip_parameters: List["TipPositionParameters"], + final_z: float, + seek_speed: float, + tip_definition: "Prep.TipPickupParameters", + blowout_offset: float = 4.0, + blowout_speed: float = 0.0, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=11, + parameters=[ + (tip_parameters, ParameterTypes.StructureArray), + (final_z, ParameterTypes.Real32Bit), + (seek_speed, ParameterTypes.Real32Bit), + (tip_definition, ParameterTypes.Structure), + (blowout_offset, ParameterTypes.Real32Bit), + (blowout_speed, ParameterTypes.Real32Bit), + (enable_tadm, ParameterTypes.Bool), + (dispenser_volume, ParameterTypes.Real32Bit), + (dispenser_speed, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_tool_by_id( + self, + tip_definition_id: int, + tool_position_x: float, + tool_position_z: float, + front_channel_position_y: float, + rear_channel_position_y: float, + tool_seek: float, + tool_x_radius: float, + tool_y_radius: float, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=14, + parameters=[ + (tip_definition_id, ParameterTypes.UInt8Bit), + (tool_position_x, ParameterTypes.Real32Bit), + (tool_position_z, ParameterTypes.Real32Bit), + (front_channel_position_y, ParameterTypes.Real32Bit), + (rear_channel_position_y, ParameterTypes.Real32Bit), + (tool_seek, ParameterTypes.Real32Bit), + (tool_x_radius, ParameterTypes.Real32Bit), + (tool_y_radius, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_tool( + self, + tip_definition: "Prep.TipPickupParameters", + tool_position_x: float, + tool_position_z: float, + front_channel_position_y: float, + rear_channel_position_y: float, + tool_seek: float, + tool_x_radius: float, + tool_y_radius: float, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=15, + parameters=[ + (tip_definition, ParameterTypes.Structure), + (tool_position_x, ParameterTypes.Real32Bit), + (tool_position_z, ParameterTypes.Real32Bit), + (front_channel_position_y, ParameterTypes.Real32Bit), + (rear_channel_position_y, ParameterTypes.Real32Bit), + (tool_seek, ParameterTypes.Real32Bit), + (tool_x_radius, ParameterTypes.Real32Bit), + (tool_y_radius, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def drop_tool( + self, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=16, + parameters=[], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def pick_up_plate( + self, + plate_top_center: "Prep.XYZCoord", + plate: "Prep.PlateDimensions", + clearance_y: float, + grip_speed_y: float, + grip_distance: float, + grip_height: float, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=17, + parameters=[ + (plate_top_center, ParameterTypes.Structure), + (plate, ParameterTypes.Structure), + (clearance_y, ParameterTypes.Real32Bit), + (grip_speed_y, ParameterTypes.Real32Bit), + (grip_distance, ParameterTypes.Real32Bit), + (grip_height, ParameterTypes.Real32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def drop_plate( + self, + plate_top_center: "Prep.XYZCoord", + clearance_y: float, + acceleration_scale_x: int = 100, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=18, + parameters=[ + (plate_top_center, ParameterTypes.Structure), + (clearance_y, ParameterTypes.Real32Bit), + (acceleration_scale_x, ParameterTypes.UInt8Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def move_plate( + self, + plate_top_center: "Prep.XYZCoord", + acceleration_scale_x: int = 100, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=19, + parameters=[ + (plate_top_center, ParameterTypes.Structure), + (acceleration_scale_x, ParameterTypes.UInt8Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def transfer_plate( + self, + plate_source_top_center: "Prep.XYZCoord", + plate_destination_top_center: "Prep.XYZCoord", + plate: "Prep.PlateDimensions", + clearance_y: float, + grip_speed_y: float, + grip_distance: float, + grip_height: float, + acceleration_scale_x: int = 100, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=20, + parameters=[ + (plate_source_top_center, ParameterTypes.Structure), + (plate_destination_top_center, ParameterTypes.Structure), + (plate, ParameterTypes.Structure), + (clearance_y, ParameterTypes.Real32Bit), + (grip_speed_y, ParameterTypes.Real32Bit), + (grip_distance, ParameterTypes.Real32Bit), + (grip_height, ParameterTypes.Real32Bit), + (acceleration_scale_x, ParameterTypes.UInt8Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def release_plate( + self, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=21, + parameters=[], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def empty_dispenser( + self, + channels: List["ChannelIndex"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=23, + parameters=[ + (channels, ParameterTypes.EnumArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def move_to_position( + self, + move_parameters: "Prep.GantryMoveXYZParameters", + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=26, + parameters=[ + (move_parameters, ParameterTypes.Structure), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def move_to_position_via_lane( + self, + move_parameters: "Prep.GantryMoveXYZParameters", + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=27, + parameters=[ + (move_parameters, ParameterTypes.Structure), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def move_z_up_to_safe( + self, + channels: List["ChannelIndex"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=28, + parameters=[ + (channels, ParameterTypes.EnumArray), + ], + timeout=timeout, + # harp_source=self.pipettor_source, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), + harp_destination=self.pipettor_destination, + ) + + async def z_seek_lld_position( + self, + seek_parameters: List["LLDChannelSeekParameters"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=29, + parameters=[ + (seek_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def create_tadm_limit_curve( + self, + channel: "Prep.ChannelIndex", + name: str, + lower_limit: List["LimitCurveEntry"], + upper_limit: List["LimitCurveEntry"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=31, + parameters=[ + (channel, ParameterTypes.UInt32Bit), + (name, ParameterTypes.String), + (lower_limit, ParameterTypes.StructureArray), + (upper_limit, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def erase_tadm_limit_curves( + self, + channel: "Prep.ChannelIndex", + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=32, + parameters=[ + (channel, ParameterTypes.UInt32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def get_tadm_limit_curve_names( + self, + channel: "Prep.ChannelIndex", + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=33, + parameters=[ + (channel, ParameterTypes.UInt32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def get_tadm_limit_curve_info( + self, + channel: "Prep.ChannelIndex", + name: str, + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=34, + parameters=[ + (channel, ParameterTypes.UInt32Bit), + (name, ParameterTypes.String), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def retrieve_tadm_data( + self, + channel: "Prep.ChannelIndex", + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=35, + parameters=[ + (channel, ParameterTypes.UInt32Bit), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def reset_tadm_fifo( + self, + channels: List["ChannelIndex"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=36, + parameters=[ + (channels, ParameterTypes.EnumArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_v2( + self, + aspirate_parameters: List["AspirateParametersNoLldAndMonitoring2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=38, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_tadm_v2( + self, + aspirate_parameters: List["AspirateParametersNoLldAndTadm2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=39, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_lld_v2( + self, + aspirate_parameters: List["AspirateParametersLldAndMonitoring2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=40, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def aspirate_lld_tadm_v2( + self, + aspirate_parameters: List["AspirateParametersLldAndTadm2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=41, + parameters=[ + (aspirate_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def dispense_v2( + self, + dispense_parameters: List["DispenseParametersNoLld2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=42, + parameters=[ + (dispense_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def dispense_lld_v2( + self, + dispense_parameters: List["DispenseParametersLld2"], + timeout: Optional[float] = None, + ) -> bytes: + return await self.send_command( + command_id=43, + parameters=[ + (dispense_parameters, ParameterTypes.StructureArray), + ], + timeout=timeout, + harp_source=self.pipettor_source, + harp_destination=self.pipettor_destination, + ) + + async def initialize( + self, + tip_drop_params: InitTipDropParameters, + smart: bool = False, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=1, + parameters=[ + (smart, ParameterTypes.Bool), + (tip_drop_params, ParameterTypes.Structure), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def park( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=3, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def spread( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=4, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def add_tip_and_needle_definition( + self, + parameters_: "Prep.TipDefinition", + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=12, + parameters=[ + (parameters_, ParameterTypes.Structure), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def remove_tip_and_needle_definition( + self, + id_: int, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=13, + parameters=[ + (id_, ParameterTypes.Enum), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def read_storage( + self, + offset: int, + length: int, + timeout: Optional[float] = None, + ) -> bytes: + result = await self.send_command( + command_id=14, + parameters=[ + (offset, ParameterTypes.UInt32Bit), + (length, ParameterTypes.UInt32Bit), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + return result + + async def write_storage( + self, + offset: int, + data: bytes, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=15, + parameters=[ + (offset, ParameterTypes.UInt32Bit), + (data, ParameterTypes.UInt8Array), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def power_down_request( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=17, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def confirm_power_down( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=18, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def cancel_power_down( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=19, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def remove_channel_power_for_head_swap( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=23, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def restore_channel_power_after_head_swap( + self, + delay_ms: int, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=24, + parameters=[ + (delay_ms, ParameterTypes.UInt32Bit), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def set_deck_light( + self, + white: int, + red: int, + green: int, + blue: int, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=25, + parameters=[ + (white, ParameterTypes.UInt8Bit), + (red, ParameterTypes.UInt8Bit), + (green, ParameterTypes.UInt8Bit), + (blue, ParameterTypes.UInt8Bit), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0005, 0x0002)), + harp_destination=self.destination_address, + ) + + async def disco_mode(self): + """Easter egg""" + for _ in range(69): + self.set_deck_light( + white=random.randint(1, 255), + red=random.randint(1, 255), + green=random.randint(1, 255), + blue=random.randint(1, 255), + ) + time.sleep(0.1) + + async def get_deck_light( + self, + timeout: Optional[float] = None, + ) -> "Tuple[int, int, int, int]": + result = await self.send_command( + command_id=26, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + if len(result) != 4: + raise ValueError("Invalid return length for deck light data.") + white, red, green, blue = result + return white, red, green, blue + + async def suspended_park( + self, + move_parameters: "Prep.GantryMoveXYZParameters", + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=29, + parameters=[ + (move_parameters, ParameterTypes.Structure), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def method_begin( + self, + automatic_pause: bool = False, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=30, + parameters=[ + (automatic_pause, ParameterTypes.Bool), + ], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def method_end( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=31, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def method_abort( + self, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=33, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + + async def is_parked( + self, + timeout: Optional[float] = None, + ) -> bool: + result = await self.send_command( + command_id=34, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + if len(result) != 1: + raise ValueError("Invalid return length for is_parked status.") + return bool(result[0]) + + async def is_spread( + self, + timeout: Optional[float] = None, + ) -> bool: + result = await self.send_command( + command_id=35, + parameters=[], + timeout=timeout, + harp_source=self.source_address, + harp_destination=self.destination_address, + ) + if len(result) != 1: + raise ValueError("Invalid return length for is_spread status.") + return bool(result[0]) + + # custom + + async def z_travel_configuration( + self, + unknown: int, + timeout: Optional[float] = None, + ) -> None: + return await self.send_command( + command_id=13, + parameters=[ + (unknown, ParameterTypes.Enum), + ], + timeout=timeout, + harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0005)), + harp_destination=Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBEF0)), + ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py new file mode 100644 index 00000000000..89781370fa0 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py @@ -0,0 +1,267 @@ +import unittest + +from pylabrobot.liquid_handling.backends.hamilton.prep import ( + ParameterTypes, + Prep, + encode_data_fragment, +) +from pylabrobot.liquid_handling.liquid_handler import LiquidHandler +from pylabrobot.resources.celltreat.plates import CellTreat_96_wellplate_350ul_Ub +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton.hamilton_decks import PrepDeck +from pylabrobot.resources.hamilton.tip_racks import STF + + +class PrepTransportLayerTests(unittest.TestCase): + ip_packet_data = bytes.fromhex( + "2000063000000200040001000100010004BF020002101C0000000000010001000000" + ) + harp_packet_data = bytes.fromhex("0200040001000100010004BF020002101C0000000000010001000000") + hoi_packet_data = bytes.fromhex("010001000000") + + def test_decode_ip_packet(self): + ip_packet = Prep.IpPacket.decode(self.ip_packet_data) + assert ip_packet.size == 32 + assert ip_packet.protocol == 6 + assert ip_packet.version == (3, 0) + assert ip_packet.options_length == 0 + assert ip_packet.options == None + assert ip_packet.payload == self.harp_packet_data + + def test_encode_ip_packet(self): + ip_packet = Prep.IpPacket( + protocol=Prep.IpPacket.TransportableProtocol.Harp2, + version=(3, 0), + options=None, + payload=self.harp_packet_data, + ) + data = ip_packet.encode() + assert data == self.ip_packet_data + + def test_decode_harp_packet(self): + harp_packet = Prep.HarpPacket.decode(self.harp_packet_data) + assert harp_packet.source == Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0001)) + assert harp_packet.destination == Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBF04)) + assert harp_packet.sequence_number == 2 + assert harp_packet.reserved_1 == 0 + assert harp_packet.protocol == 2 + assert harp_packet.action == Prep.HarpPacket.Action(0x10) + assert harp_packet.length == 28 + assert harp_packet.options_length == 0 + assert harp_packet.options == [] + assert harp_packet.version == 0 + assert harp_packet.reserved_2 == 0 + assert harp_packet.payload == self.hoi_packet_data + + def test_encode_harp_packet(self): + harp_packet = Prep.HarpPacket( + source=Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0001)), + destination=Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBF04)), + sequence_number=2, + reserved_1=0, + protocol=Prep.HarpPacket.HarpTransportableProtocol.Hoi2, + action=Prep.HarpPacket.Action(0x10), + options=[], + version=0, + reserved_2=0, + payload=self.hoi_packet_data, + ) + data = harp_packet.encode() + assert data == self.harp_packet_data + + def test_decode_hoi_packet(self): + hoi_packet = Prep.HoiPacket2.decode(self.hoi_packet_data) + assert hoi_packet.interface_id == 1 + assert hoi_packet.action == 0 + assert hoi_packet.action_id == 1 + assert hoi_packet.version == 0 + assert hoi_packet.number_of_fragments == 0 + + def test_encode_hoi_packet(self): + hoi_packet = Prep.HoiPacket2( + interface_id=1, action=0, action_id=1, version=0, data_fragments=[] + ) + data = hoi_packet.encode() + assert data == self.hoi_packet_data + + def test_encode_data_fragment(self): + assert encode_data_fragment(152.600, ParameterTypes.Real32Bit) == bytes.fromhex( + "280004009A991843" + ) + assert encode_data_fragment(False, ParameterTypes.Bool) == bytes.fromhex("170102000000") + assert encode_data_fragment(True, ParameterTypes.Bool) == bytes.fromhex("170102000100") + + +class PrepFirmwareInterfaceTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.prep = Prep() + self.prep.socket = unittest.mock.MagicMock() + self.deck = PrepDeck() + self.lh = LiquidHandler(backend=self.prep, deck=self.deck) + + self.tip_rack = STF(name="tr") + self.deck.assign_child_resource( + self.tip_rack, location=Coordinate(x=140.9, y=98.53, z=49.57) + ) # spot 7 + self.plate = CellTreat_96_wellplate_350ul_Ub(name="plate") + self.deck.assign_child_resource(self.plate, location=Coordinate(x=1.55, y=76.58, z=0)) # spot 3 + return await super().asyncSetUp() + + async def test_setup(self): + data = bytes.fromhex( + "440006300000020004000400010001000015070002134000000000000103010000021701020000001e001a001701020001002800040000808f4328000400000040401f000000" + ) + self.prep._id = 0x6 + self.prep.socket.recv.return_value = bytes.fromhex( + "200006300000010001000015020004000400010002041c0000000000010401000000" + ) + + await self.lh.setup() + self.prep.socket.send.assert_called_with(data) + + async def test_park(self): + data = bytes.fromhex("200006300000020004000400010001000015150002131C0000000000010303000000") + self.prep._id = 0x14 + self.prep.socket.recv.return_value = bytes.fromhex( + "200006300000010001000015020004000400090002041c0000000000010403000000" + ) + await self.prep.park() + self.prep.socket.send.assert_called_with(data) + + async def test_z_travel_configuration(self): + data = bytes.fromhex( + "28000630000002000400050001000100f0be0a00021324000000000001030d0000012000040003000000" + ) + self.prep._id = 0x9 + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000001000100f0be0200040005000a0002041c000000000001040d000000" + ) + await self.prep.z_travel_configuration(unknown=3) + self.prep.socket.send.assert_called_with(data) + + async def test_pick_up_tips(self): + await self.test_setup() + + data = bytes.fromhex( + "e2000630000002000700060000e00100001008000213de00000000000103090000071f0064001e002e001701020000002000040002000000280004009a991843280004007b5419432800040048e16b4228000400a4f08d421e002e001701020000002000040001000000280004009a991843280004007b5410432800040048e16b4228000400a4f08d422800040071bdf74228000400000070411e003000170102000000280004000000b443280004009a994f42200004000200000017010200010017010200000017010200000017010200000028000400000000002800040000007a43" + ) + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e001000010020007000600100002041c000000000001040c000000" + ) + + self.prep._id = 0x07 + await self.lh.pick_up_tips(self.tip_rack["C1", "D1"]) + self.prep.socket.send.assert_called_with(data) + + async def test_aspirate(self): + await self.test_pick_up_tips() + data = bytes.fromhex( + "60010630000002000700060000e0010000100b0002135c01000000000103010000011f003c011e00380117010200000020000400020000001e0026001701020000002800040066667c41280004005c6f1643280004000000000028000400000000001e00640017010200000017010200010028000400c3f5a0c028000400a4f0c1422800040000000040280004000000c842280004000000c84228000400000000002800040033334b4028000400000000002800040000000000280004000000803f06000400000000001e002c0017010200000028000400a4f0bd4228000400a4f0c142170102000000280004000000004028000400000000001e002400170102000100280004000000000028000400000000000401020000002800040000007a431e00140017010200010017010200010028000400000090401e002400170102000100170102000000170102000000050002001e00050002001e00050002001400" + ) + self.prep._id = 0x0A + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e0010000100200070006000b0002041c0000000000010401000000" + ) + await self.lh.aspirate(self.plate["A1"], vols=[100]) + self.prep.socket.send.assert_called_with(data) + + async def test_dispense(self): + await self.test_aspirate() + data = bytes.fromhex( + "50010630000002000700060000e0010000100d0002134c01000000000103050000011f002c011e00280117010200000020000400020000001e0026001701020000002800040066667c41280004005c6f16432800040000000000280004000000c8421e00640017010200000017010200010028000400c3f5a0c028000400a4f0c1422800040000000040280004000000c842280004000000c84228000400000000002800040033334b4028000400000000002800040000000000280004000000000006000400000000001e002c0017010200000028000400a4f0bd4228000400f628c642170102000000280004000000004028000400000000001e002400170102000100280004000000000028000400000000000401020000002800040000007a431e00140017010200010017010200010028000400000090401e0014001701020001000500020000002000040001000000" + ) + self.prep._id = 0x0C + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e0010000100200070006000d0002041c0000000000010405000000" + ) + await self.lh.dispense(self.plate["A1"], vols=[100]) + self.prep.socket.send.assert_called_with(data) + + async def test_drop_tips(self): + await self.test_pick_up_tips() + data = bytes.fromhex( + "b0000630000002000700060000e00100001004000213ac000000000001030c0000041f0074001e0036001701020000002000040002000000280004009a991843280004007b5419432800040048e16b4228000400a4f08d4220000400000000001e0036001701020000002000040001000000280004009a991843280004007b5410432800040048e16b4228000400a4f08d4220000400000000002800040071bdf74228000400000020412800040000000000" + ) + self.prep._id = 0x03 + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e001000010020007000600040002041c000000000001040c000000" + ) + await self.lh.return_tips() + self.prep.socket.send.assert_called_with(data) + + async def test_move_z_up_to_safe(self): + data = bytes.fromhex( + "2c000630000002000700060000e0010000100500021328000000000001031c000001230008000100000002000000" + ) + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e001000010020007000600050002041c000000000001041c000000" + ) + self.prep._id = 0x04 + await self.prep.move_z_up_to_safe( + channels=[Prep.ChannelIndex.FrontChannel, Prep.ChannelIndex.RearChannel] + ) + self.prep.socket.send.assert_called_with(data) + + async def test_move_to_position(self): + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e001000010020007000600060002041c000000000001041a000000" + ) + self.prep._id = 0x05 + await self.prep.move_to_position( + move_parameters=Prep.GantryMoveXYZParameters( + default_values=False, + gantry_x_position=100, + axis_parameters=[ + Prep.ChannelYZMoveParameters( + default_values=True, + channel=Prep.ChannelIndex.RearChannel, + y_position=185.2, + z_position=100, + ), + Prep.ChannelYZMoveParameters( + default_values=False, + channel=Prep.ChannelIndex.FrontChannel, + y_position=0, + z_position=100, + ), + ], + ) + ) + data = bytes.fromhex( + "7a000630000002000700060000e0010000100600021376000000000001031a0000011e005600170102000000280004000000c8421f0044001e001e0017010200010020000400020000002800040033333943280004000000c8421e001e0017010200000020000400010000002800040000000000280004000000c842" + ) + self.prep.socket.send.assert_called_with(data) + + async def test_move_to_position_via_lane(self): + self.prep.socket.recv.return_value = bytes.fromhex( + "20000630000000e001000010020007000600070002041c000000000001041b000000" + ) + self.prep._id = 0x06 + await self.prep.move_to_position_via_lane( + move_parameters=Prep.GantryMoveXYZParameters( + default_values=False, + gantry_x_position=152.6, + axis_parameters=[ + Prep.ChannelYZMoveParameters( + default_values=True, + channel=Prep.ChannelIndex.RearChannel, + y_position=153.33, + z_position=70.97, + ), + Prep.ChannelYZMoveParameters( + default_values=False, + channel=Prep.ChannelIndex.FrontChannel, + y_position=144.33, + z_position=70.97, + ), + ], + ) + ) + data = bytes.fromhex( + "7a000630000002000700060000e0010000100700021376000000000001031b0000011e005600170102000000280004009a9918431f0044001e001e001701020001002000040002000000280004007b54194328000400a4f08d421e001e001701020000002000040001000000280004007b54104328000400a4f08d42" + ) + self.prep.socket.send.assert_called_with(data) + + async def test_move_channel(self): + # await self.lh.move_channel_x() + pass diff --git a/pylabrobot/resources/deck.py b/pylabrobot/resources/deck.py index 2f78f13b93c..e27e515478e 100644 --- a/pylabrobot/resources/deck.py +++ b/pylabrobot/resources/deck.py @@ -20,10 +20,10 @@ class Deck(Resource): def __init__( self, + size_x: float, + size_y: float, + size_z: float, name: str = "deck", - size_x: float = 1360, - size_y: float = 653.5, - size_z: float = 900, origin: Coordinate = Coordinate(0, 0, 0), category: str = "deck", ): diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 54000467468..47bbcd36046 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,6 +1,7 @@ from .hamilton_decks import ( HamiltonDeck, HamiltonSTARDeck, + PrepDeck, STARDeck, STARLetDeck, ) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 4f618227c00..b0c12719273 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -498,3 +498,33 @@ def STARDeck( with_trash96=with_trash96, with_teaching_rack=with_teaching_rack, ) + + +class PrepDeck(Deck): + def __init__( + self, name="deck", size_x=0, size_y=0.5, size_z=0, origin=Coordinate.zero(), category="deck" + ): + super().__init__( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, origin=origin, category=category + ) + for column in range(2): + for row in range(4): + x = column * 140 + y = row * 100 # ? + spot = ResourceHolder( + name=f"spot_{column}_{row}", + size_x=127.76, + size_y=96.52, + size_z=0, + ) + self.assign_child_resource(spot, location=Coordinate(x, y, 0)) + + trash = Trash(name="trash", size_x=0, size_y=0, size_z=0) + # TODO: y coordinate + self.assign_child_resource(trash, location=Coordinate(287.0, 0, 0)) + + def __getitem__(self, key: int) -> ResourceHolder: + return self.children[key] + + def __setitem__(self, key: int, value: Resource): + self.children[key].assign_child_resource(value) From 6a80b543154aa1de2c8971f935444fb30e563447 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Sat, 10 May 2025 23:39:44 +0100 Subject: [PATCH 02/42] Fix Prep Import & Access `smart` During `.setup()` (#496) --- .../backends/hamilton/__init__.py | 1 + .../liquid_handling/backends/hamilton/prep.py | 24 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/__init__.py index 4c36917049f..c32450df3b5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/backends/hamilton/__init__.py @@ -2,5 +2,6 @@ from .base import HamiltonLiquidHandler from .pump import Pump # TODO: move elsewhere. +from .prep import Prep from .STAR_backend import STAR from .vantage_backend import Vantage diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep.py b/pylabrobot/liquid_handling/backends/hamilton/prep.py index b315ac291e2..46734c591f4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep.py @@ -8,12 +8,12 @@ from pylabrobot.liquid_handling.backends import LiquidHandlerBackend from pylabrobot.liquid_handling.standard import ( - Aspiration, - AspirationContainer, - AspirationPlate, - Dispense, - DispenseContainer, - DispensePlate, + SingleChannelAspiration, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + SingleChannelDispense, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, Drop, DropTipRack, Pickup, @@ -260,7 +260,7 @@ def __init__(self, host: str = "192.168.100.102", port: int = 2000): self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - async def setup(self): + async def setup(self, smart: bool = False): self.socket.connect((self.host, self.port)) self.socket.settimeout(30) @@ -271,7 +271,7 @@ async def setup(self): rolloff_distance=3, channel_parameters=[], ), - smart=False, + smart=smart, ) await super().setup() @@ -2465,7 +2465,7 @@ async def drop_tips( async def aspirate( self, - ops: List[Aspiration], + ops: List[SingleChannelAspiration], use_channels: List[int], z_final: float = 96.97, timeout: Optional[float] = None, @@ -2543,7 +2543,7 @@ async def aspirate( async def dispense( self, - ops: List[Dispense], + ops: List[SingleChannelDispense], use_channels: List[int], final_z: float = 96.97, timeout: Optional[float] = None, @@ -2623,10 +2623,10 @@ async def pick_up_tips96(self, pickup: PickupTipRack): async def drop_tips96(self, drop: DropTipRack): raise NotImplementedError("This operation is not supported on the Prep") - async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): + async def aspirate96(self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]): raise NotImplementedError("This operation is not supported on the Prep") - async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): raise NotImplementedError("This operation is not supported on the Prep") async def pick_up_resource(self, pickup: ResourcePickup): From 96a736a466e050e63201553784ce8101c954a56e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 10 May 2025 18:06:54 -0700 Subject: [PATCH 03/42] fix --- pylabrobot/liquid_handling/backends/hamilton/prep.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep.py b/pylabrobot/liquid_handling/backends/hamilton/prep.py index 46734c591f4..bd85e2c980b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep.py @@ -1,3 +1,4 @@ +import asyncio import random import socket import struct @@ -3433,13 +3434,13 @@ async def set_deck_light( async def disco_mode(self): """Easter egg""" for _ in range(69): - self.set_deck_light( + await self.set_deck_light( white=random.randint(1, 255), red=random.randint(1, 255), green=random.randint(1, 255), blue=random.randint(1, 255), ) - time.sleep(0.1) + await asyncio.sleep(0.1) async def get_deck_light( self, From 8aa601c2cfcf0a740f7bd70d33fc66b68339e076 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:58:06 -0800 Subject: [PATCH 04/42] Nimbus Setup Fix --- .../backends/hamilton/nimbus_backend.py | 2 ++ .../liquid_handling/backends/hamilton/tcp_backend.py | 12 ++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 7018e22590b..589641abb00 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1093,6 +1093,8 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) except Exception as e: logger.warning(f"Failed to unlock door: {e}") + self.setup_finished = True + async def _discover_instrument_objects(self): """Discover instrument-specific objects using introspection.""" introspection = HamiltonIntrospection(self) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 2f71a0d9d55..820181d98ef 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -99,6 +99,7 @@ def __init__( auto_reconnect: Enable automatic reconnection max_reconnect_attempts: Maximum reconnection attempts """ + super().__init__() self.io = Socket( host=host, @@ -540,15 +541,9 @@ async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> # Build command message message = command.build() - # Log command parameters for debugging + # Log command parameters at debug level (noisy when discovering subobjects) log_params = command.get_log_params() - logger.info(f"{command.__class__.__name__} parameters:") - for key, value in log_params.items(): - # Format arrays nicely if very long - if isinstance(value, list) and len(value) > 8: - logger.info(f" {key}: {value[:4]}... ({len(value)} items)") - else: - logger.info(f" {key}: {value}") + logger.debug("%s parameters: %s", command.__class__.__name__, log_params) # Send command await self.write(message) @@ -578,6 +573,7 @@ async def stop(self): logger.warning(f"Error during stop: {e}") finally: self._connected = False + self.setup_finished = False logger.info("Hamilton backend stopped") def serialize(self) -> dict: From df6cf7e6f4902198f0f1771cfe9ab5cd23f3f416 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:09:09 -0800 Subject: [PATCH 05/42] Add missing/Prep specific dtypes to tcp_backend. Refine ensure_connection logic before tcp_backend's send_command. --- .../backends/hamilton/tcp/introspection.py | 12 +- .../backends/hamilton/tcp/messages.py | 119 ++++++++++++- .../backends/hamilton/tcp/protocol.py | 7 + .../backends/hamilton/tcp/wire_types.py | 166 ++++++++++++++++++ .../backends/hamilton/tcp_backend.py | 71 +++++++- 5 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 247de40fde1..d1649981d1e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -585,7 +585,7 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return ObjectInfo( name=response["name"], @@ -606,7 +606,7 @@ async def get_method(self, address: Address, method_index: int) -> MethodInfo: Method signature """ command = GetMethodCommand(address, method_index) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return MethodInfo( interface_id=response["interface_id"], @@ -630,7 +630,7 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> Subobject address """ command = GetSubobjectAddressCommand(address, subobject_index) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) # Type: ignore needed because response dict is typed as dict[str, Any] # but we know 'address' key contains Address object @@ -646,7 +646,7 @@ async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: List of interface information """ command = GetInterfacesCommand(address) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return [ InterfaceInfo( @@ -666,7 +666,7 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] List of enum definitions """ command = GetEnumsCommand(address, interface_id) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return [ EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) @@ -684,7 +684,7 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI List of struct definitions """ command = GetStructsCommand(address, interface_id) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return [ StructInfo( diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index df32f5289ab..986e6e6326c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -119,9 +119,17 @@ def i64(self, value: int) -> "HoiParams": data = Writer().i64(value).finish() return self._add_fragment(HamiltonDataType.I64, data) - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" + def u8(self, value: int, padded: bool = False) -> "HoiParams": + """Add unsigned 8-bit integer parameter. + + Args: + value: Value to encode. + padded: If True, use flags=0x01 and append pad byte (Prep compatibility). + """ data = Writer().u8(value).finish() + if padded: + data += b"\x00" + return self._add_fragment(HamiltonDataType.U8, data, flags=0x01) return self._add_fragment(HamiltonDataType.U8, data) def u16(self, value: int) -> "HoiParams": @@ -156,11 +164,58 @@ def string(self, value: str) -> "HoiParams": data = Writer().string(value).finish() return self._add_fragment(HamiltonDataType.STRING, data) - def bool_value(self, value: bool) -> "HoiParams": - """Add boolean parameter.""" + def bool_value(self, value: bool, padded: bool = False) -> "HoiParams": + """Add boolean parameter. + + Args: + value: The boolean value. + padded: If True, use Prep-compatible encoding: flags=0x01 and append pad + byte [type=23][flags=1][len=2][0x00 or 0x01][0x00]. Default False for + Nimbus compatibility. + """ data = Writer().u8(1 if value else 0).finish() + if padded: + data += b"\x00" + return self._add_fragment(HamiltonDataType.BOOL, data, flags=0x01) return self._add_fragment(HamiltonDataType.BOOL, data) + # Enum and structure types (Prep) + def enum_value(self, value: int) -> "HoiParams": + """Add enum parameter (encoded as u32, type_id=32).""" + data = Writer().u32(value).finish() + return self._add_fragment(HamiltonDataType.ENUM, data) + + def hc_result(self, value: int) -> "HoiParams": + """Add HC_RESULT parameter (same wire format as u16, type_id=33).""" + data = Writer().u16(value).finish() + return self._add_fragment(HamiltonDataType.HC_RESULT, data) + + def enum_array(self, values: list[int]) -> "HoiParams": + """Add array of enums (each u32, type_id=35).""" + writer = Writer() + for val in values: + writer.u32(val) + return self._add_fragment(HamiltonDataType.ENUM_ARRAY, writer.finish()) + + def structure(self, nested: "HoiParams") -> "HoiParams": + """Add nested structure (type_id=30). Data is concatenated DataFragments.""" + return self._add_fragment(HamiltonDataType.STRUCTURE, nested.build()) + + def structure_from_bytes(self, data: bytes) -> "HoiParams": + """Add structure (type_id=30) from raw concatenated DataFragment bytes. + + Used when building from Prep dataclasses that still use .encode(). + """ + return self._add_fragment(HamiltonDataType.STRUCTURE, data) + + def structure_array(self, elements: list["HoiParams"]) -> "HoiParams": + """Add array of structures (type_id=31). Each element wrapped as Structure fragment.""" + inner = b"" + for elem in elements: + payload = elem.build() + inner += Writer().u8(HamiltonDataType.STRUCTURE).u8(0).u16(len(payload)).raw_bytes(payload).finish() + return self._add_fragment(HamiltonDataType.STRUCTURE_ARRAY, inner) + # Array types def i8_array(self, values: list[int]) -> "HoiParams": """Add array of signed 8-bit integers. @@ -284,6 +339,35 @@ def string_array(self, values: list[str]) -> "HoiParams": writer.string(val) return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ + + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. + + Fields without ``Annotated`` metadata (e.g. plain ``Address``) are skipped. + The polymorphic ``WireType.encode_into`` on each annotation handles all + dispatch -- no if/elif required here. + """ + from dataclasses import fields as dc_fields + from typing import get_type_hints + + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import WireType + + hints = get_type_hints(type(obj), include_extras=True) + params = cls() + for f in dc_fields(obj): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + params = meta.encode_into(getattr(obj, f.name), params) + return params + def build(self) -> bytes: """Return concatenated DataFragments.""" return b"".join(self._fragments) @@ -366,10 +450,35 @@ def _parse_value(self, type_id: int, data: bytes) -> Any: except ValueError: pass # Not a valid enum value, continue to other checks - # Special case: bool + # Special case: bool (may have padding byte; we read first byte only) if type_id == HamiltonDataType.BOOL: return reader.u8() == 1 + # Enum and structure types + if type_id == HamiltonDataType.ENUM: + return reader.u32() + if type_id == HamiltonDataType.HC_RESULT: + return reader.u16() + if type_id == HamiltonDataType.STRUCTURE: + return data # Raw bytes; caller can HoiParamsParser(data).parse_all() + if type_id == HamiltonDataType.ENUM_ARRAY: + count = len(data) // 4 + return [reader.u32() for _ in range(count)] + if type_id == HamiltonDataType.STRUCTURE_ARRAY: + # Data is concatenated Structure fragments: [type=30][flags][len:2][payload]... + result = [] + r = Reader(data) + while r.offset() + 4 <= len(data): + frag_type = r.u8() + _frag_flags = r.u8() + frag_len = r.u16() + if frag_type != HamiltonDataType.STRUCTURE: + raise ValueError(f"Expected STRUCTURE fragment in STRUCTURE_ARRAY, got type {frag_type}") + if r.offset() + frag_len > len(data): + raise ValueError("STRUCTURE_ARRAY element extends beyond buffer") + result.append(r.raw_bytes(frag_len)) + return result + # Dispatch table for array element parsers array_element_parsers = { HamiltonDataType.I8_ARRAY: reader.i8, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py index 9e916e91db3..a02c6b01dc2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py @@ -152,6 +152,13 @@ class HamiltonDataType(IntEnum): STRING = 15 BOOL = 23 + # Structure and enum types (Prep and introspection) + STRUCTURE = 30 + STRUCTURE_ARRAY = 31 + ENUM = 32 + HC_RESULT = 33 # Same wire format as U16, used for error codes + ENUM_ARRAY = 35 + # Array types U8_ARRAY = 22 I8_ARRAY = 24 diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py new file mode 100644 index 00000000000..0f6efa24461 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py @@ -0,0 +1,166 @@ +"""Wire type system for Hamilton DataFragment encoding. + +Provides polymorphic WireType objects used as ``typing.Annotated`` metadata on +dataclass fields. A single generic serialiser (``HoiParams.from_struct``) +reads these annotations and delegates to ``WireType.encode_into`` -- no +if/elif chains, no lambda dispatch tables. + +Usage on a dataclass field:: + + @dataclass + class DropTipParameters: + default_values: PaddedBool + channel: Enum + y_position: F32 + +Serialise with:: + + HoiParams.from_struct(drop_tip_params) +""" + +from __future__ import annotations + +import struct as _struct +from typing import TYPE_CHECKING, Annotated + +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonDataType + +if TYPE_CHECKING: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams + + +# --------------------------------------------------------------------------- +# WireType hierarchy +# --------------------------------------------------------------------------- + + +class WireType: + """Base class: a wire-format type that can encode values into HoiParams.""" + + __slots__ = ("type_id",) + + def __init__(self, type_id: int): + self.type_id = type_id + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError + + +class Scalar(WireType): + """Fixed-size scalar encoded via ``struct.pack(fmt, value)``. + + When *padded* is ``True`` the Prep convention is used: flags byte = 0x01 + and one ``\\x00`` pad byte is appended after the value. + """ + + __slots__ = ("fmt", "padded") + + def __init__(self, type_id: int, fmt: str, padded: bool = False): + super().__init__(type_id) + self.fmt = fmt + self.padded = padded + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(self.fmt, value) + if self.padded: + data += b"\x00" + return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) + + +class Array(WireType): + """Homogeneous array of packed scalars (no length prefix on the wire).""" + + __slots__ = ("element_fmt",) + + def __init__(self, type_id: int, element_fmt: str): + super().__init__(type_id) + self.element_fmt = element_fmt + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(f"{len(value)}{self.element_fmt}", *value) + return params._add_fragment(self.type_id, data) + + +class Struct(WireType): + """Nested structure -- recurse via ``HoiParams.from_struct``.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP + + return params.structure(HP.from_struct(value)) + + +class StructArray(WireType): + """Array of nested structures.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP + + return params.structure_array([HP.from_struct(v) for v in value]) + + +class StringType(WireType): + """Null-terminated ASCII string.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + return params.string(value) + + +# --------------------------------------------------------------------------- +# Annotated type aliases +# --------------------------------------------------------------------------- + +# Scalars (mypy sees the base Python type: int / float / bool / str) +I8 = Annotated[int, Scalar(HamiltonDataType.I8, "b")] +I16 = Annotated[int, Scalar(HamiltonDataType.I16, "h")] +I32 = Annotated[int, Scalar(HamiltonDataType.I32, "i")] +I64 = Annotated[int, Scalar(HamiltonDataType.I64, "q")] +U8 = Annotated[int, Scalar(HamiltonDataType.U8, "B")] +U16 = Annotated[int, Scalar(HamiltonDataType.U16, "H")] +U32 = Annotated[int, Scalar(HamiltonDataType.U32, "I")] +U64 = Annotated[int, Scalar(HamiltonDataType.U64, "Q")] +F32 = Annotated[float, Scalar(HamiltonDataType.F32, "f")] +F64 = Annotated[float, Scalar(HamiltonDataType.F64, "d")] +Bool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?")] +Enum = Annotated[int, Scalar(HamiltonDataType.ENUM, "I")] +HcResult = Annotated[int, Scalar(HamiltonDataType.HC_RESULT, "H")] +Str = Annotated[str, StringType()] + +# Prep-padded variants (Bool and U8 are always padded on Prep hardware) +PaddedBool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?", padded=True)] +PaddedU8 = Annotated[int, Scalar(HamiltonDataType.U8, "B", padded=True)] + +# Arrays (mypy sees ``list``) +I8Array = Annotated[list, Array(HamiltonDataType.I8_ARRAY, "b")] +I16Array = Annotated[list, Array(HamiltonDataType.I16_ARRAY, "h")] +I32Array = Annotated[list, Array(HamiltonDataType.I32_ARRAY, "i")] +I64Array = Annotated[list, Array(HamiltonDataType.I64_ARRAY, "q")] +U8Array = Annotated[list, Array(HamiltonDataType.U8_ARRAY, "B")] +U16Array = Annotated[list, Array(HamiltonDataType.U16_ARRAY, "H")] +U32Array = Annotated[list, Array(HamiltonDataType.U32_ARRAY, "I")] +U64Array = Annotated[list, Array(HamiltonDataType.U64_ARRAY, "Q")] +F32Array = Annotated[list, Array(HamiltonDataType.F32_ARRAY, "f")] +F64Array = Annotated[list, Array(HamiltonDataType.F64_ARRAY, "d")] +BoolArray = Annotated[list, Array(HamiltonDataType.BOOL_ARRAY, "?")] +EnumArray = Annotated[list, Array(HamiltonDataType.ENUM_ARRAY, "I")] + +# Compound types: Structure and StructureArray do NOT have simple aliases +# because ``Annotated[object, Struct()]`` would erase the concrete type for +# mypy. Use inline ``Annotated[ConcreteType, Struct()]`` on each field to +# preserve full type safety. The class singletons are exported so call-sites +# only need ``Struct()`` and ``StructArray()``. diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 820181d98ef..810273cb2d8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -15,6 +15,7 @@ from pylabrobot.io.socket import Socket from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import GetObjectCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, @@ -32,7 +33,6 @@ logger = logging.getLogger(__name__) - @dataclass class HamiltonError: """Hamilton error response.""" @@ -498,6 +498,38 @@ def _parse_registration_response(self, response: RegistrationResponse) -> list[A return objects + async def _probe_connection(self) -> None: + """Probe the connection by sending ObjectInfo (iface=0, id=1) to the root object. + + If the root has not been discovered yet (e.g. during initial setup), this method + returns immediately — the connection is being established for the first time so + there is nothing to probe against. + + On a retryable connection failure (broken pipe, reset, timeout) the backend is + marked as disconnected and a full reconnect is attempted before returning. After + a successful reconnect the caller can send the real command knowing the connection + is healthy. + + This is called with ensure_connection=False so it never recurses. + """ + root = self._discovered_objects.get("root", []) + if not root: + return + + try: + await self.send_command(GetObjectCommand(root[0]), ensure_connection=False) + except ( + BrokenPipeError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + ) as e: + logger.warning( + f"{self.io._unique_id} Connection probe failed, reconnecting: {e}" + ) + self._connected = False + await self._reconnect() + def _allocate_sequence_number(self, dest_address: Address) -> int: """Allocate next sequence number for destination. @@ -512,23 +544,44 @@ def _allocate_sequence_number(self, dest_address: Address) -> int: self._sequence_numbers[dest_address] = next_seq return next_seq - async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + async def send_command( + self, + command: HamiltonCommand, + ensure_connection: bool = True, + ) -> Optional[dict]: """Send Hamilton command and wait for response. Sets source_address if not already set by caller (for testing). Uses backend's client_address assigned during Protocol 7 initialization. + When ensure_connection=True (default), probes the root object with a + lightweight ObjectInfo call before sending. If the probe detects a dead + connection it reconnects before the real command is sent, so the command + is never sent on a broken connection and is never sent twice. + + Pass ensure_connection=False for commands that are part of the setup / + discovery flow (e.g. HamiltonIntrospection calls), or when a root object + address has not yet been discovered. + + Read/write timeouts are enforced at the backend level (read_timeout and + write_timeout passed into HamiltonTCPBackend and used by the Socket). + Args: - command: Hamilton command to execute - timeout: Maximum time to wait for response + command: Hamilton command to execute. + ensure_connection: If True, probe the root object before sending and + reconnect if the probe fails. If False, send directly without probing. Returns: - Parsed response dictionary, or None if command has no information to extract + Parsed response dictionary, or None if the command has no return data. Raises: - TimeoutError: If no response received within timeout - HamiltonError: If command returned an error + ConnectionError: If the connection is not established and auto_reconnect + is disabled, or if reconnection fails. + RuntimeError: If the Hamilton firmware returns an error action code. """ + if ensure_connection: + await self._probe_connection() + # Set source address with smart fallback if command.source_address is None: if self.client_address is None: @@ -543,12 +596,12 @@ async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> # Log command parameters at debug level (noisy when discovering subobjects) log_params = command.get_log_params() - logger.debug("%s parameters: %s", command.__class__.__name__, log_params) + logger.debug(f"{command.__class__.__name__} parameters: {log_params}") # Send command await self.write(message) - # Read response (timeout handled by TCP layer) + # Read response (uses backend read_timeout) response_message = await self._read_one_message() assert isinstance(response_message, CommandResponse) From 3389ee4fc7839333315f36d6a8b7ea4ded95375f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:10:06 -0800 Subject: [PATCH 06/42] Formatting --- .../liquid_handling/backends/hamilton/tcp_backend.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 810273cb2d8..a5ae2b80e58 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -518,12 +518,7 @@ async def _probe_connection(self) -> None: try: await self.send_command(GetObjectCommand(root[0]), ensure_connection=False) - except ( - BrokenPipeError, - ConnectionResetError, - ConnectionAbortedError, - TimeoutError, - ) as e: + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError) as e: logger.warning( f"{self.io._unique_id} Connection probe failed, reconnecting: {e}" ) From 7fa0ea9f23fe4728666ded603c6ed3c0cdb2b377 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:34:48 -0800 Subject: [PATCH 07/42] Prep backend init --- .../backends/hamilton/prep_backend.py | 1899 +++++++++++++++++ 1 file changed, 1899 insertions(+) create mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep_backend.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py new file mode 100644 index 00000000000..f0cb8cb5399 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -0,0 +1,1899 @@ +"""Hamilton Prep backend implementation. + +Uses HamiltonTCPBackend (Protocol 7/3, introspection) and shares the same +TCP codec as NimbusBackend. Discovers MLPrep, Pipettor, and ChannelCoordinator +via introspection and sends commands as HamiltonCommand subclasses. + +Three-layer design: + +- **Inner structs** (e.g. ``CommonParameters``, ``NoLldParameters``): reusable + wire protocol building blocks, no defaults. ``.default()`` only where it + means "tell firmware to use its own defaults". +- **Command classes** (e.g. ``PrepDropTips``): pure wire shape + identity. + ``@dataclass`` with ``dest: Address`` + ``Annotated`` payload fields, no + defaults. ``build_parameters()`` uses ``HoiParams.from_struct(self)``. +- **PrepBackend methods**: single source of truth for all Prep-specific + defaults. Flat, named, typed kwargs with defaults. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import IntEnum +from typing import TYPE_CHECKING, Annotated, List, Optional, Union + +from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + HamiltonIntrospection, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + EnumArray, + F32, + I16, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U16, + U32, + U8Array, + Enum as WEnum, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import Tip + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Enums (mirrored from Prep protocol spec, independent of prep.py) +# ============================================================================= + + +class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + +class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + +class TipTypes(IntEnum): + None_ = 0 + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + +class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + +# ============================================================================= +# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) +# ============================================================================= + + +@dataclass +class SeekParameters: + x_start: F32 + y_start: F32 + z_start: F32 + distance: F32 + expected_position: F32 + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + height: F32 + + +@dataclass +class TipDefinition: + default_values: PaddedBool + id: PaddedU8 + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + label: Str + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def for_op( + cls, + loc, + op: "SingleChannelAspiration", + prewet_volume: float = 0.0, + ) -> "AspirateParameters": + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=op.blow_out_air_volume or 0.0, + ) + + +@dataclass +class DispenseParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + stop_back_volume: F32 + cutoff_speed: F32 + + @classmethod + def for_op( + cls, + loc, + stop_back_volume: float = 0.0, + cutoff_speed: float = 100.0, + ) -> "DispenseParameters": + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + + +@dataclass +class CommonParameters: + default_values: PaddedBool + empty: PaddedBool + z_minimum: F32 + z_final: F32 + z_liquid_exit_speed: F32 + liquid_volume: F32 + liquid_speed: F32 + transport_air_volume: F32 + tube_radius: F32 + cone_height: F32 + cone_bottom_radius: F32 + settling_time: F32 + additional_probes: U32 + + @classmethod + def for_op( + cls, + volume: float, + radius: float, + *, + flow_rate: Optional[float] = None, + empty: bool = True, + z_minimum: float = -5.03, + z_final: float = 96.97, + z_liquid_exit_speed: float = 2.0, + transport_air_volume: float = 0.0, + cone_height: float = 0.0, + cone_bottom_radius: float = 0.0, + settling_time: float = 1.0, + additional_probes: int = 0, + ) -> "CommonParameters": + return cls( + default_values=False, + empty=empty, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + liquid_volume=volume, + liquid_speed=flow_rate or 100.0, + transport_air_volume=transport_air_volume, + tube_radius=radius, + cone_height=cone_height, + cone_bottom_radius=cone_bottom_radius, + settling_time=settling_time, + additional_probes=additional_probes, + ) + + +@dataclass +class NoLldParameters: + default_values: PaddedBool + z_fluid: F32 + z_air: F32 + bottom_search: PaddedBool + z_bottom_search_offset: F32 + z_bottom_offset: F32 + + @classmethod + def for_fixed_z( + cls, + z_fluid: float = 94.97, + z_air: float = 96.97, + *, + z_bottom_search_offset: float = 2.0, + z_bottom_offset: float = 0.0, + ) -> "NoLldParameters": + return cls( + default_values=False, + z_fluid=z_fluid, + z_air=z_air, + bottom_search=False, + z_bottom_search_offset=z_bottom_search_offset, + z_bottom_offset=z_bottom_offset, + ) + + +@dataclass +class LldParameters: + default_values: PaddedBool + z_seek: F32 + z_seek_speed: F32 + z_submerge: F32 + z_out_of_liquid: F32 + + +@dataclass +class CLldParameters: + default_values: PaddedBool + sensitivity: WEnum + clot_check_enable: PaddedBool + z_clot_check: F32 + detect_mode: WEnum + + +@dataclass +class PLldParameters: + default_values: PaddedBool + sensitivity: WEnum + dispenser_seek_speed: F32 + lld_height_difference: F32 + detect_mode: WEnum + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + data: I16Array + + +@dataclass +class TadmParameters: + default_values: PaddedBool + limit_curve_index: U16 + recording_mode: WEnum + + @classmethod + def default(cls) -> TadmParameters: + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=TadmRecordingModes.Errors, + ) + + +@dataclass +class AspirateMonitoringParameters: + default_values: PaddedBool + c_lld_enable: PaddedBool + p_lld_enable: PaddedBool + minimum_differential: U16 + maximum_differential: U16 + clot_threshold: U16 + + @classmethod + def default(cls) -> AspirateMonitoringParameters: + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + +@dataclass +class MixParameters: + default_values: PaddedBool + z_offset: F32 + volume: F32 + cycles: PaddedU8 + speed: F32 + + @classmethod + def default(cls) -> MixParameters: + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + +@dataclass +class AdcParameters: + default_values: PaddedBool + errors: PaddedBool + maximum_volume: F32 + + @classmethod + def default(cls) -> AdcParameters: + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + pressure: U16 + + +@dataclass +class LiquidHeightReturnParameters: + default_values: PaddedBool + channel: WEnum + c_lld_detected: PaddedBool + c_lld_liquid_height: F32 + p_lld_detected: PaddedBool + p_lld_liquid_height: F32 + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + offset: PaddedU8 + + +@dataclass +class YLLDSeekParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_position_y: F32 + seek_velocity_y: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class ChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + + +@dataclass +class LLDChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_velocity_z: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + dispense_counter: U32 + + +@dataclass +class ChannelCalibrationParameters: + default_values: PaddedBool + channel: WEnum + dispenser_return_steps: U32 + squeeze_position: F32 + z_touchoff: F32 + z_tip_height: F32 + pressure_monitoring_shift: U32 + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + high_pressure: PaddedBool + + +@dataclass +class LeakCheckParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_distance_y: F32 + pre_load_distance_y: F32 + final_z: F32 + tip_definition_id: PaddedU8 + test_time: F32 + high_pressure: PaddedBool + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + in_home_sensor: PaddedBool + + +@dataclass +class ChannelDriveStatus: + default_values: PaddedBool + channel: WEnum + y_axis_drive_status: Annotated[DriveStatus, Struct()] + z_axis_drive_status: Annotated[DriveStatus, Struct()] + dispenser_drive_status: Annotated[DriveStatus, Struct()] + squeeze_drive_status: Annotated[DriveStatus, Struct()] + + +@dataclass +class AspirateParametersNoLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DropTipParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_seek: F32 + z_tip: F32 + z_final: F32 + z_seek_speed: F32 + drop_type: WEnum + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + pressure: I16 + + +@dataclass +class TipPositionParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + + +@dataclass +class TipDropParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + drop_type: WEnum + + +@dataclass +class TipHeightCalibrationParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + z_final: F32 + volume: F32 + tip_type: WEnum + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + height: F32 + + +@dataclass +class AspirateParametersNoLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +@dataclass +class PrepCommand(HamiltonCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with ``dest: Address`` (inherited) plus any + ``Annotated`` payload fields. ``build_parameters()`` calls + ``HoiParams.from_struct(self)`` which serialises only ``Annotated`` fields, + so ``dest`` is automatically excluded from the wire payload. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpTips(PrepCommand): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedlesById(PrepCommand): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedles(PrepCommand): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepDropTips(PrepCommand): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + tip_definition_id: PaddedU8 + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepPickUpTool(PrepCommand): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + tip_definition: Annotated[TipPickupParameters, Struct()] + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepDropTool(PrepCommand): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + +@dataclass +class PrepDropPlate(PrepCommand): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepMovePlate(PrepCommand): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepTransferPlate(PrepCommand): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + plate_source_top_center: Annotated[XYZCoord, Struct()] + plate_destination_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepReleasePlate(PrepCommand): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + + +@dataclass +class PrepEmptyDispenser(PrepCommand): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move to position (cmd=26, dest=Pipettor or ChannelCoordinator).""" + + command_id = 26 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move to position via lane (cmd=27, dest=Pipettor or ChannelCoordinator).""" + + command_id = 27 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveZUpToSafe(PrepCommand): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + +@dataclass +class PrepEraseTadmLimitCurves(PrepCommand): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepCommand): + """Query whether MLPrep is initialized. + + From introspection (MLPrepRoot.MLPrep): iface=1 id=2 GetIsInitialized(()) -> value: I64. + Sent as STATUS_REQUEST (0); response is STATUS_RESPONSE (1) with one I64. + """ + + command_id = 2 # GetIsInitialized per introspection_output/MLPrepRoot_MLPrep.txt + action_code = 0 # STATUS_REQUEST (query methods use 0, like Nimbus IsInitialized) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetIsInitialized response: single I64 (value), exposed as bool.""" + parser = HoiParamsParser(data) + _, value = parser.parse_next() # I64 + return {"initialized": bool(value)} + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepCommand): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + + +@dataclass +class PrepSuspendedPark(PrepCommand): + """Suspended park / move to load position (cmd=29, dest=MLPrep).""" + + command_id = 29 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + + +@dataclass +class PrepIsParked(PrepCommand): + """Query parked status (cmd=34, dest=MLPrep).""" + + command_id = 34 + + +@dataclass +class PrepIsSpread(PrepCommand): + """Query spread status (cmd=35, dest=MLPrep).""" + + command_id = 35 + + +# ============================================================================= +# PrepBackend +# ============================================================================= + +_CHANNEL_INDEX = { + 0: ChannelIndex.RearChannel, + 1: ChannelIndex.FrontChannel, +} + + +class PrepBackend(HamiltonTCPBackend): + """Backend for Hamilton Prep instruments using the shared TCP stack. + + Discovers MLPrep, Pipettor, and ChannelCoordinator via Protocol-7/3 + introspection, then sends typed PrepCommand instances. All parameter + serialisation is handled by HoiParams.from_struct() -- no manual builder + calls. + """ + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + ) + self._mlprep_address: Optional[Address] = None + self._pipettor_address: Optional[Address] = None + self._coordinator_address: Optional[Address] = None + + # --------------------------------------------------------------------------- + # Setup & discovery + # --------------------------------------------------------------------------- + + async def setup(self, smart: bool = True, force_initialize: bool = False): + """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. + + Order: + 1. TCP + Protocol 7/3 init and root discovery (super().setup()) + 2. Discover Prep objects: MLPrep, Pipettor, ChannelCoordinator + 3. If force_initialize: always run Initialize(smart=smart). + Else: query GetIsInitialized; only run Initialize(smart=smart) when not initialized. + 4. Mark setup complete. + + Args: + smart: When we call Initialize, pass this to the firmware (default True). + force_initialize: If True, always run Initialize. If False, run Initialize only + when GetIsInitialized reports not initialized (e.g. reconnect-safe). + """ + await super().setup() + await self._discover_prep_objects() + + if self._mlprep_address is None: + raise RuntimeError("MLPrep object not discovered. Cannot proceed with setup.") + + if force_initialize: + await self._run_initialize(smart=smart) + logger.info("Prep initialization complete (force_initialize=True)") + else: + try: + already = await self.is_initialized() + except Exception as e: + logger.error("GetIsInitialized failed; cannot decide whether to init: %s", e) + raise + if already: + logger.info("MLPrep already initialized, skipping Initialize") + else: + await self._run_initialize(smart=smart) + logger.info("Prep initialization complete") + + self.setup_finished = True + + async def _run_initialize(self, smart: bool): + """Send PrepInitialize to MLPrep (shared by setup).""" + await self.send_command( + PrepInitialize( + dest=self._mlprep_dest(), + smart=smart, + tip_drop_params=InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + ) + ) + + async def _discover_prep_objects(self): + """Discover MLPrep, Pipettor, and ChannelCoordinator via introspection.""" + introspection = HamiltonIntrospection(self) + root_objects = self._discovered_objects.get("root", []) + if not root_objects: + logger.warning("No root objects discovered") + return + + root_addr = root_objects[0] + try: + root_info = await introspection.get_object(root_addr) + for i in range(root_info.subobject_count): + try: + sub_addr = await introspection.get_subobject_address(root_addr, i) + sub_info = await introspection.get_object(sub_addr) + + if sub_info.name == "MLPrep": + self._mlprep_address = sub_addr + logger.info(f"Found MLPrep at {sub_addr}") + + if sub_info.name == "ChannelCoordinator": + self._coordinator_address = sub_addr + logger.info(f"Found ChannelCoordinator at {sub_addr}") + + if sub_info.name == "PipettorRoot": + for j in range(sub_info.subobject_count): + try: + pip_sub_addr = await introspection.get_subobject_address(sub_addr, j) + pip_sub_info = await introspection.get_object(pip_sub_addr) + if pip_sub_info.name == "Pipettor": + self._pipettor_address = pip_sub_addr + logger.info(f"Found Pipettor at {pip_sub_addr}") + break + except Exception as e: + logger.debug(f"Failed to get PipettorRoot subobject {j}: {e}") + except Exception as e: + logger.debug(f"Failed to get root subobject {i}: {e}") + except Exception as e: + logger.warning(f"Failed to discover Prep objects: {e}") + + # --------------------------------------------------------------------------- + # Properties + # --------------------------------------------------------------------------- + + @property + def mlprep_address(self) -> Optional[Address]: + return self._mlprep_address + + @property + def pipettor_address(self) -> Optional[Address]: + return self._pipettor_address + + @property + def coordinator_address(self) -> Optional[Address]: + return self._coordinator_address + + @property + def num_channels(self) -> int: + """Prep has 2 channels (front and rear).""" + return 2 + + def _pipettor_dest(self) -> Address: + dest = self._pipettor_address or self._coordinator_address + if dest is None: + raise RuntimeError("Pipettor/Coordinator not discovered. Call setup() first.") + return dest + + def _mlprep_dest(self) -> Address: + if self._mlprep_address is None: + raise RuntimeError("MLPrep address not discovered. Call setup() first.") + return self._mlprep_address + + async def is_initialized(self) -> bool: + """Query whether MLPrep reports as initialized (GetIsInitialized, cmd=2). + + Uses MLPrep method from introspection: GetIsInitialized(()) -> value: I64. + Requires MLPrep to be discovered (e.g. after super().setup() and + _discover_prep_objects()). Call before or after PrepInitialize to test. + """ + result = await self.send_command(PrepGetIsInitialized(dest=self._mlprep_dest())) + if result is None: + return False + return result.get("initialized", False) + + # --------------------------------------------------------------------------- + # LiquidHandlerBackend abstract methods + # --------------------------------------------------------------------------- + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + final_z: float = 123.87, + seek_speed: float = 15.0, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + ): + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported" + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + tip_positions: List[TipPositionParameters] = [] + for ch in range(2): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "t") + z = loc.z + op.resource.get_tip().total_tip_length + tip_positions.append( + TipPositionParameters( + default_values=False, + channel=_CHANNEL_INDEX[ch], + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z + 12, + ) + ) + + assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip type" + tip = ops[0].tip + tip_definition = TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + + await self.send_command( + PrepPickUpTips( + dest=self._pipettor_dest(), + tip_positions=tip_positions, + final_z=final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + ) + ) + + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + final_z: float = 123.87, + seek_speed: float = 10.0, + tip_roll_off_distance: float = 0.0, + ): + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported" + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + tip_positions: List[TipDropParameters] = [] + for ch in range(2): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "t") + z = loc.z + op.resource.get_tip().total_tip_length + tip_positions.append( + TipDropParameters( + default_values=False, + channel=_CHANNEL_INDEX[ch], + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z + 12, + drop_type=TipDropType.FixedHeight, + ) + ) + + await self.send_command( + PrepDropTips( + dest=self._pipettor_dest(), + tip_positions=tip_positions, + final_z=final_z, + seek_speed=seek_speed, + tip_roll_off_distance=tip_roll_off_distance, + ) + ) + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + z_final: float = 96.97, + z_fluid: float = 94.97, + z_air: float = 96.97, + settling_time: float = 1.0, + transport_air_volume: float = 0.0, + z_liquid_exit_speed: float = 2.0, + z_minimum: float = -5.03, + z_bottom_search_offset: float = 2.0, + ): + """Aspirate from the given resources (NoLLD path). + + All optional kwargs override wire-protocol defaults and are passed through + to CommonParameters and NoLldParameters. Example:: + + await backend.aspirate(ops, [0], z_final=95.0, settling_time=2.0) + """ + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported" + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + aspirate_parameters: List[AspirateParametersNoLldAndMonitoring] = [] + for ch in range(2): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" + radius = op.resource.get_size_x() / 2 + aspirate_parameters.append( + AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=AspirateParameters.for_op(loc, op), + common=CommonParameters.for_op( + op.volume, radius, + flow_rate=op.flow_rate, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ), + no_lld=NoLldParameters.for_fixed_z( + z_fluid, z_air, + z_bottom_search_offset=z_bottom_search_offset, + ), + mix=MixParameters.default(), + adc=AdcParameters.default(), + aspirate_monitoring=AspirateMonitoringParameters.default(), + ) + ) + + await self.send_command( + PrepAspirateNoLldMonitoring( + dest=self._pipettor_dest(), + aspirate_parameters=aspirate_parameters, + ) + ) + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + final_z: float = 96.97, + z_fluid: float = 94.97, + z_air: float = 99.08, + settling_time: float = 0.0, + transport_air_volume: float = 0.0, + z_liquid_exit_speed: float = 2.0, + z_minimum: float = -5.03, + z_bottom_search_offset: float = 2.0, + ): + """Dispense to the given resources (NoLLD path). + + All optional kwargs override wire-protocol defaults and are passed through + to CommonParameters and NoLldParameters. Example:: + + await backend.dispense(ops, [0], final_z=95.0, settling_time=0.5) + """ + assert len(ops) == len(use_channels) + assert max(use_channels) <= 2, "Only two channels are supported" + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + dispense_parameters: List[DispenseParametersNoLld] = [] + for ch in range(2): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" + radius = op.resource.get_size_x() / 2 + dispense_parameters.append( + DispenseParametersNoLld( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=DispenseParameters.for_op(loc), + common=CommonParameters.for_op( + op.volume, radius, + flow_rate=op.flow_rate, + z_minimum=z_minimum, + z_final=final_z, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ), + no_lld=NoLldParameters.for_fixed_z( + z_fluid, z_air, + z_bottom_search_offset=z_bottom_search_offset, + ), + mix=MixParameters.default(), + tadm=TadmParameters.default(), + adc=AdcParameters.default(), + ) + ) + + await self.send_command( + PrepDispenseNoLld( + dest=self._pipettor_dest(), + dispense_parameters=dispense_parameters, + ) + ) + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("pick_up_tips96 is not supported on the Prep") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("drop_tips96 is not supported on the Prep") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + raise NotImplementedError("aspirate96 is not supported on the Prep") + + async def dispense96( + self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + ): + raise NotImplementedError("dispense96 is not supported on the Prep") + + async def pick_up_resource(self, pickup: ResourcePickup): + raise NotImplementedError("pick_up_resource is not yet implemented on the Prep") + + async def move_picked_up_resource(self, move: ResourceMove): + raise NotImplementedError("move_picked_up_resource is not yet implemented on the Prep") + + async def drop_resource(self, drop: ResourceDrop): + raise NotImplementedError("drop_resource is not yet implemented on the Prep") + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return True + + # --------------------------------------------------------------------------- + # MLPrep convenience methods + # --------------------------------------------------------------------------- + + async def park(self) -> None: + """Park the instrument.""" + await self.send_command(PrepPark(dest=self._mlprep_dest())) + + async def spread(self) -> None: + """Spread channels.""" + await self.send_command(PrepSpread(dest=self._mlprep_dest())) + + async def method_begin(self, automatic_pause: bool = False) -> None: + """Signal the start of a liquid-handling method.""" + await self.send_command( + PrepMethodBegin( + dest=self._mlprep_dest(), + automatic_pause=automatic_pause, + ) + ) + + async def method_end(self) -> None: + """Signal the end of a liquid-handling method.""" + await self.send_command(PrepMethodEnd(dest=self._mlprep_dest())) + + async def method_abort(self) -> None: + """Abort the current method.""" + await self.send_command(PrepMethodAbort(dest=self._mlprep_dest())) + + async def set_deck_light( + self, white: int, red: int, green: int, blue: int + ) -> None: + """Set the deck LED colour.""" + await self.send_command( + PrepSetDeckLight( + dest=self._mlprep_dest(), + white=white, + red=red, + green=green, + blue=blue, + ) + ) + + # --------------------------------------------------------------------------- + # Pipettor convenience methods + # --------------------------------------------------------------------------- + + async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> None: + """Move to position (cmd=26).""" + await self.send_command( + PrepMoveToPosition( + dest=self._pipettor_dest(), + move_parameters=move_parameters, + ) + ) + + async def move_to_position_via_lane(self, move_parameters: GantryMoveXYZParameters) -> None: + """Move to position via lane (cmd=27).""" + await self.send_command( + PrepMoveToPositionViaLane( + dest=self._pipettor_dest(), + move_parameters=move_parameters, + ) + ) From 3d98d73d6442bc4993f3688be139536067d9d919 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:48:37 -0800 Subject: [PATCH 08/42] Terraform tcp codec. Consolidatated wire protocol now that we more context. Make corresponding adjustments to Nimbus backend. --- .../backends/hamilton/nimbus_backend.py | 982 +++++------------- .../backends/hamilton/nimbus_backend_tests.py | 109 +- .../backends/hamilton/tcp/commands.py | 31 +- .../backends/hamilton/tcp/introspection.py | 219 ++-- .../backends/hamilton/tcp/messages.py | 540 ++-------- .../backends/hamilton/tcp/protocol.py | 57 +- .../backends/hamilton/tcp/tcp_tests.py | 452 +++++++- .../backends/hamilton/tcp/wire_types.py | 238 ++++- .../backends/hamilton/tcp_backend.py | 258 +++-- 9 files changed, 1354 insertions(+), 1532 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 589641abb00..9cd936cf4a1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -8,20 +8,23 @@ import enum import logging +from dataclasses import dataclass from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( - HamiltonIntrospection, -) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( - HoiParams, - HoiParamsParser, -) +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonProtocol, +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + Bool, + BoolArray, + I16Array, + I32, + I32Array, + U16, + U16Array, + U32Array, ) from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend from pylabrobot.liquid_handling.standard import ( @@ -144,761 +147,277 @@ def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: # ============================================================================ -class LockDoor(HamiltonCommand): - """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" +@dataclass +class NimbusCommand(HamiltonCommand): + """Base for Nimbus commands. Subclasses are dataclasses with dest + Annotated payload fields. + + build_parameters() -> HoiParams.from_struct(self); dest is skipped (no Annotated). + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 + dest: Address + + def __post_init__(self) -> None: + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + +@dataclass +class LockDoor(NimbusCommand): + """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" + command_id = 1 -class UnlockDoor(HamiltonCommand): +@dataclass +class UnlockDoor(NimbusCommand): """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 2 -class IsDoorLocked(HamiltonCommand): +@dataclass +class IsDoorLocked(NimbusCommand): """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 3 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsDoorLocked response.""" - parser = HoiParamsParser(data) - _, locked = parser.parse_next() - return {"locked": bool(locked)} + @dataclass(frozen=True) + class Response: + locked: Bool -class PreInitializeSmart(HamiltonCommand): +@dataclass +class PreInitializeSmart(NimbusCommand): """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 32 -class InitializeSmartRoll(HamiltonCommand): +@dataclass +class InitializeSmartRoll(NimbusCommand): """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 29 + # All position/distance fields in 0.01 mm units + x_positions: I32Array + y_positions: I32Array + begin_tip_deposit_process: I32Array # Z start positions + end_tip_deposit_process: I32Array # Z stop positions + z_position_at_end_of_a_command: I32Array + roll_distances: I32Array - def __init__( - self, - dest: Address, - x_positions: List[int], - y_positions: List[int], - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize InitializeSmartRoll command. - Args: - dest: Destination address (NimbusCore) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distances in 0.01mm units - """ - super().__init__(dest) - self.x_positions = x_positions - self.y_positions = y_positions - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class IsInitialized(HamiltonCommand): +@dataclass +class IsInitialized(NimbusCommand): """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 14 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsInitialized response.""" - parser = HoiParamsParser(data) - _, initialized = parser.parse_next() - return {"initialized": bool(initialized)} + @dataclass(frozen=True) + class Response: + value: Bool -class IsTipPresent(HamiltonCommand): +@dataclass +class IsTipPresent(NimbusCommand): """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 16 action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsTipPresent response - returns List[i16].""" - parser = HoiParamsParser(data) - # Parse array of i16 values representing tip presence per channel - _, tip_presence = parser.parse_next() - return {"tip_present": tip_presence} + @dataclass(frozen=True) + class Response: + tip_present: I16Array -class GetChannelConfiguration_1(HamiltonCommand): +@dataclass +class GetChannelConfiguration_1(NimbusCommand): """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 15 action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. - - Returns: (channels: u16, channel_types: List[i16]) - """ - parser = HoiParamsParser(data) - _, channels = parser.parse_next() - _, channel_types = parser.parse_next() - return {"channels": channels, "channel_types": channel_types} + @dataclass(frozen=True) + class Response: + channels: U16 + channel_types: I16Array -class SetChannelConfiguration(HamiltonCommand): +@dataclass +class SetChannelConfiguration(NimbusCommand): """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 67 + channel: U16 # Channel number (1-based) + indexes: I16Array # e.g. [1,3,4]: 1=Tip Recognition, 2=pLLD, 3=cLLD aspirate, 4=cLLD clot + enables: BoolArray # Enable flag per index - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - enables: List[bool], - ): - """Initialize SetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [1, 3, 4]) - 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, - 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD - enables: List of enable flags (e.g., [True, False, False, False]) - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - self.enables = enables - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) - -class Park(HamiltonCommand): +@dataclass +class Park(NimbusCommand): """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 3 -class PickupTips(HamiltonCommand): +@dataclass +class PickupTips(NimbusCommand): """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 4 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_pick_up_process: List[int], - end_tip_pick_up_process: List[int], - tip_types: List[int], - ): - """Initialize PickupTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_pick_up_process: Z start positions in 0.01mm units - end_tip_pick_up_process: Z stop positions in 0.01mm units - tip_types: Tip type integers for each channel - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_pick_up_process = begin_tip_pick_up_process - self.end_tip_pick_up_process = end_tip_pick_up_process - self.tip_types = tip_types - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_pick_up_process) - .i32_array(self.end_tip_pick_up_process) - .u16_array(self.tip_types) - ) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive per channel) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_pick_up_process: I32Array # Z start, 0.01 mm + end_tip_pick_up_process: I32Array # Z stop, 0.01 mm + tip_types: U16Array # Tip type id per channel -class DropTips(HamiltonCommand): +@dataclass +class DropTips(NimbusCommand): """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 5 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - default_waste: bool, - ): - """Initialize DropTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - default_waste: If True, drop to default waste (positions may be ignored) - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.default_waste = default_waste - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .bool_value(self.default_waste) - ) - - -class DropTipsRoll(HamiltonCommand): + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_deposit_process: I32Array # Z start, 0.01 mm + end_tip_deposit_process: I32Array # Z stop, 0.01 mm + z_position_at_end_of_a_command: I32Array # 0.01 mm + default_waste: Bool # If True, drop to default waste (positions may be ignored) + + +@dataclass +class DropTipsRoll(NimbusCommand): """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 82 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize DropTipsRoll command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distance for each channel in 0.01mm units - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class EnableADC(HamiltonCommand): + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_deposit_process: I32Array # Z start, 0.01 mm + end_tip_deposit_process: I32Array # Z stop, 0.01 mm + z_position_at_end_of_a_command: I32Array # 0.01 mm + roll_distances: I32Array # 0.01 mm per channel + + +@dataclass +class EnableADC(NimbusCommand): """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 43 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize EnableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) -class DisableADC(HamiltonCommand): +@dataclass +class DisableADC(NimbusCommand): """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 44 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize DisableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) -class GetChannelConfiguration(HamiltonCommand): +@dataclass +class GetChannelConfiguration(NimbusCommand): """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 66 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + channel: U16 # Channel number (1-based) + indexes: I16Array # e.g. [2] for "Aspirate monitoring with cLLD" - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - ): - """Initialize GetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration response. - - Returns: { enabled: List[bool] } - """ - parser = HoiParamsParser(data) - _, enabled = parser.parse_next() - return {"enabled": enabled} + @dataclass(frozen=True) + class Response: + enabled: BoolArray -class Aspirate(HamiltonCommand): +@dataclass +class Aspirate(NimbusCommand): """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 6 - - def __init__( - self, - dest: Address, - aspirate_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - clot_detection_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - blow_out_air_volume: List[int], - pre_wetting_volume: List[int], - aspirate_volume: List[int], - transport_air_volume: List[int], - aspiration_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - dp_lld_sensitivity: List[int], - lld_height_difference: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Aspirate command. - - Args: - dest: Destination address (Pipette) - aspirate_type: Aspirate type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - clot_detection_height: Clot detection height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units - aspirate_volume: Aspirate volume for each channel in 0.1uL units - transport_air_volume: Transport air volume for each channel in 0.1uL units - aspiration_speed: Aspirate speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) - lld_height_difference: LLD height difference for each channel in 0.01mm units - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.aspirate_type = aspirate_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.clot_detection_height = clot_detection_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.blow_out_air_volume = blow_out_air_volume - self.pre_wetting_volume = pre_wetting_volume - self.aspirate_volume = aspirate_volume - self.transport_air_volume = transport_air_volume - self.aspiration_speed = aspiration_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.dp_lld_sensitivity = dp_lld_sensitivity - self.lld_height_difference = lld_height_difference - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.aspirate_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32_array(self.clot_detection_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.blow_out_air_volume) - .u32_array(self.pre_wetting_volume) - .u32_array(self.aspirate_volume) - .u32_array(self.transport_air_volume) - .u32_array(self.aspiration_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .i16_array(self.dp_lld_sensitivity) - .i32_array(self.lld_height_difference) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -class Dispense(HamiltonCommand): + aspirate_type: I16Array # Per channel (I16) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + lld_search_height: I32Array # 0.01 mm + liquid_height: I32Array # 0.01 mm + immersion_depth: I32Array # 0.01 mm + surface_following_distance: I32Array # 0.01 mm + minimum_height: I32Array # 0.01 mm + clot_detection_height: I32Array # 0.01 mm + min_z_endpos: I32 # 0.01 mm + swap_speed: U32Array # 0.1 µL/s (on leaving liquid) + blow_out_air_volume: U32Array # 0.1 µL + pre_wetting_volume: U32Array # 0.1 µL + aspirate_volume: U32Array # 0.1 µL + transport_air_volume: U32Array # 0.1 µL + aspiration_speed: U32Array # 0.1 µL/s + settling_time: U32Array # 0.1 s + mix_volume: U32Array # 0.1 µL + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array # 0.01 mm + mix_surface_following_distance: I32Array # 0.01 mm + mix_speed: U32Array # 0.1 µL/s + tube_section_height: I32Array # 0.01 mm + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + dp_lld_sensitivity: I16Array + lld_height_difference: I32Array # 0.01 mm + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 + + +@dataclass +class Dispense(NimbusCommand): """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 7 - - def __init__( - self, - dest: Address, - dispense_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - transport_air_volume: List[int], - dispense_volume: List[int], - stop_back_volume: List[int], - blow_out_air_volume: List[int], - dispense_speed: List[int], - cut_off_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - side_touch_off_distance: int, - dispense_offset: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Dispense command. - - Args: - dest: Destination address (Pipette) - dispense_type: Dispense type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - transport_air_volume: Transport air volume for each channel in 0.1uL units - dispense_volume: Dispense volume for each channel in 0.1uL units - stop_back_volume: Stop back volume for each channel in 0.1uL units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - dispense_speed: Dispense speed for each channel in 0.1uL/s units - cut_off_speed: Cut off speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - side_touch_off_distance: Side touch off distance in 0.01mm units - dispense_offset: Dispense offset for each channel in 0.01mm units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.dispense_type = dispense_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.transport_air_volume = transport_air_volume - self.dispense_volume = dispense_volume - self.stop_back_volume = stop_back_volume - self.blow_out_air_volume = blow_out_air_volume - self.dispense_speed = dispense_speed - self.cut_off_speed = cut_off_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.side_touch_off_distance = side_touch_off_distance - self.dispense_offset = dispense_offset - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.dispense_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.transport_air_volume) - .u32_array(self.dispense_volume) - .u32_array(self.stop_back_volume) - .u32_array(self.blow_out_air_volume) - .u32_array(self.dispense_speed) - .u32_array(self.cut_off_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32(self.side_touch_off_distance) - .i32_array(self.dispense_offset) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) + dispense_type: I16Array # Per channel (I16) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + lld_search_height: I32Array # 0.01 mm + liquid_height: I32Array # 0.01 mm + immersion_depth: I32Array # 0.01 mm + surface_following_distance: I32Array # 0.01 mm + minimum_height: I32Array # 0.01 mm + min_z_endpos: I32 # 0.01 mm + swap_speed: U32Array # 0.1 µL/s (on leaving liquid) + transport_air_volume: U32Array # 0.1 µL + dispense_volume: U32Array # 0.1 µL + stop_back_volume: U32Array # 0.1 µL + blow_out_air_volume: U32Array # 0.1 µL + dispense_speed: U32Array # 0.1 µL/s + cut_off_speed: U32Array # 0.1 µL/s + settling_time: U32Array # 0.1 s + mix_volume: U32Array # 0.1 µL + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array # 0.01 mm + mix_surface_following_distance: I32Array # 0.01 mm + mix_speed: U32Array # 0.1 µL/s + side_touch_off_distance: I32 # 0.01 mm + dispense_offset: I32Array # 0.01 mm + tube_section_height: I32Array # 0.01 mm + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 # ============================================================================ @@ -946,14 +465,59 @@ def __init__( ) self._num_channels: Optional[int] = None - self._pipette_address: Optional[Address] = None - self._door_lock_address: Optional[Address] = None - self._nimbus_core_address: Optional[Address] = None self._is_initialized: Optional[bool] = None self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None self._channel_traversal_height: float = 146.0 # Default traversal height in mm + # Optional overrides for tests (when set, properties return these instead of registry lookup) + self._address_override_nimbus_core: Optional[Address] = None + self._address_override_pipette: Optional[Address] = None + self._address_override_door_lock: Optional[Address] = None + + @property + def _nimbus_core_address(self) -> Optional[Address]: + if self._address_override_nimbus_core is not None: + return self._address_override_nimbus_core + roots = self._registry.get_root_addresses() + return roots[0] if roots else None + + @_nimbus_core_address.setter + def _nimbus_core_address(self, value: Optional[Address]) -> None: + self._address_override_nimbus_core = value + + @property + def _pipette_address(self) -> Optional[Address]: + if self._address_override_pipette is not None: + return self._address_override_pipette + path = self._registry.find_path_ending_with("Pipette") + if path is None: + return None + try: + return self._registry.address(path) + except KeyError: + return None + + @_pipette_address.setter + def _pipette_address(self, value: Optional[Address]) -> None: + self._address_override_pipette = value + + @property + def _door_lock_address(self) -> Optional[Address]: + if self._address_override_door_lock is not None: + return self._address_override_door_lock + path = self._registry.find_path_ending_with("DoorLock") + if path is None: + return None + try: + return self._registry.address(path) + except KeyError: + return None + + @_door_lock_address.setter + def _door_lock_address(self, value: Optional[Address]) -> None: + self._address_override_door_lock = value + async def setup(self, unlock_door: bool = False, force_initialize: bool = False): """Set up the Nimbus backend. @@ -971,24 +535,21 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) unlock_door: If True, unlock door after initialization (default: False) force_initialize: If True, force initialization even if already initialized """ - # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration) + # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration, depth-1 discovery) await super().setup() - # Discover instrument objects - await self._discover_instrument_objects() - - # Ensure required objects are discovered - if self._pipette_address is None: - raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") + # Ensure required objects are discovered (registry populated by _discover_interfaces(max_depth=1)) if self._nimbus_core_address is None: raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") + if self._pipette_address is None: + raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") # Query channel configuration to get num_channels (use discovered address only) try: config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) assert config is not None, "GetChannelConfiguration_1 command returned None" - self._num_channels = config["channels"] - logger.info(f"Channel configuration: {config['channels']} channels") + self._num_channels = config.channels + logger.info(f"Channel configuration: {config.channels} channels") except Exception as e: logger.error(f"Failed to query channel configuration: {e}") raise @@ -1004,7 +565,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) try: init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) assert init_status is not None, "IsInitialized command returned None" - self._is_initialized = init_status.get("initialized", False) + self._is_initialized = bool(init_status.value) logger.info(f"Instrument initialized: {self._is_initialized}") except Exception as e: logger.error(f"Failed to query initialization status: {e}") @@ -1095,50 +656,6 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) self.setup_finished = True - async def _discover_instrument_objects(self): - """Discover instrument-specific objects using introspection.""" - introspection = HamiltonIntrospection(self) - - # Get root objects (already discovered in setup) - root_objects = self._discovered_objects.get("root", []) - if not root_objects: - logger.warning("No root objects discovered") - return - - # Use first root object as NimbusCore - nimbus_core_addr = root_objects[0] - self._nimbus_core_address = nimbus_core_addr - - try: - # Get NimbusCore object info - core_info = await introspection.get_object(nimbus_core_addr) - - # Discover subobjects to find Pipette and DoorLock - for i in range(core_info.subobject_count): - try: - sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) - sub_info = await introspection.get_object(sub_addr) - - # Check if this is the Pipette by interface name - if sub_info.name == "Pipette": - self._pipette_address = sub_addr - logger.info(f"Found Pipette at {sub_addr}") - - # Check if this is the DoorLock by interface name - if sub_info.name == "DoorLock": - self._door_lock_address = sub_addr - logger.info(f"Found DoorLock at {sub_addr}") - - except Exception as e: - logger.debug(f"Failed to get subobject {i}: {e}") - - except Exception as e: - logger.warning(f"Failed to discover instrument objects: {e}") - - # If door lock not found via introspection, it's not available - if self._door_lock_address is None: - logger.info("DoorLock not available on this instrument") - def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: """Returns a full-length list of size `num_channels` where positions in `channels` are filled from `values` in order; all others are `default`. Similar to one-hot encoding.""" @@ -1205,7 +722,7 @@ async def is_door_locked(self) -> bool: try: status = await self.send_command(IsDoorLocked(self._door_lock_address)) assert status is not None, "IsDoorLocked command returned None" - return bool(status["locked"]) + return bool(status.locked) except Exception as e: logger.error(f"Failed to check door lock status: {e}") raise @@ -1261,8 +778,7 @@ async def request_tip_presence(self) -> List[Optional[bool]]: raise RuntimeError("Pipette address not discovered. Call setup() first.") tip_status = await self.send_command(IsTipPresent(self._pipette_address)) assert tip_status is not None, "IsTipPresent command returned None" - tip_present = tip_status.get("tip_present", []) - return [bool(v) for v in tip_present] + return [bool(v) for v in tip_status.tip_present] def _build_waste_position_params( self, @@ -1754,7 +1270,7 @@ async def aspirate( ) ) assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False + enabled = config.enabled[0] if config.enabled else False if channel_num not in self._channel_configurations: self._channel_configurations[channel_num] = {} self._channel_configurations[channel_num][2] = enabled @@ -2053,7 +1569,7 @@ async def dispense( ) ) assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False + enabled = config.enabled[0] if config.enabled else False if channel_num not in self._channel_configurations: self._channel_configurations[channel_num] = {} self._channel_configurations[channel_num][2] = enabled diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 75c71692f91..abfea28b4b5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -31,9 +31,18 @@ UnlockDoor, _get_tip_type_from_tip, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + CommandResponse, + HoiParams, + HoiParamsParser, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + Bool, + I16Array, + U16, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol, Hoi2Action from pylabrobot.liquid_handling.standard import ( Drop, Pickup, @@ -188,15 +197,15 @@ def test_is_door_locked_command(self): # STATUS_REQUEST must have action_code=0 self.assertEqual(cmd.action_code, 0) - def test_is_door_locked_parse_response(self): - # Simulate response: bool fragment with True - response_data = HoiParams().bool_value(True).build() - result = IsDoorLocked.parse_response_parameters(response_data) - self.assertEqual(result, {"locked": True}) - - response_data = HoiParams().bool_value(False).build() - result = IsDoorLocked.parse_response_parameters(response_data) - self.assertEqual(result, {"locked": False}) + def test_is_door_locked_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsDoorLocked(Address(1, 1, 268)) + for wire_true in (True, False): + params = HoiParams().add(wire_true, Bool).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsDoorLocked.Response) + self.assertEqual(result.locked, wire_true) def test_park_command(self): cmd = Park(Address(1, 1, 48896)) @@ -209,10 +218,14 @@ def test_is_initialized_command(self): self.assertEqual(cmd.command_id, 14) self.assertEqual(cmd.action_code, 0) - def test_is_initialized_parse_response(self): - response_data = HoiParams().bool_value(True).build() - result = IsInitialized.parse_response_parameters(response_data) - self.assertEqual(result, {"initialized": True}) + def test_is_initialized_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsInitialized(Address(1, 1, 48896)) + params = HoiParams().add(True, Bool).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsInitialized.Response) + self.assertTrue(result.value) def test_is_tip_present_command(self): cmd = IsTipPresent(Address(1, 1, 257)) @@ -220,11 +233,14 @@ def test_is_tip_present_command(self): self.assertEqual(cmd.command_id, 16) self.assertEqual(cmd.action_code, 0) - def test_is_tip_present_parse_response(self): - # Simulate response with i16 array - response_data = HoiParams().i16_array([1, 0, 1, 0, 0, 0, 0, 0]).build() - result = IsTipPresent.parse_response_parameters(response_data) - self.assertEqual(result["tip_present"], [1, 0, 1, 0, 0, 0, 0, 0]) + def test_is_tip_present_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsTipPresent(Address(1, 1, 257)) + params = HoiParams().add([1, 0, 1, 0, 0, 0, 0, 0], I16Array).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsTipPresent.Response) + self.assertEqual(result.tip_present, [1, 0, 1, 0, 0, 0, 0, 0]) def test_get_channel_configuration_1_command(self): cmd = GetChannelConfiguration_1(Address(1, 1, 48896)) @@ -232,11 +248,15 @@ def test_get_channel_configuration_1_command(self): self.assertEqual(cmd.command_id, 15) self.assertEqual(cmd.action_code, 0) - def test_get_channel_configuration_1_parse_response(self): - response_data = HoiParams().u16(8).i16_array([0, 0, 0, 0, 0, 0, 0, 0]).build() - result = GetChannelConfiguration_1.parse_response_parameters(response_data) - self.assertEqual(result["channels"], 8) - self.assertEqual(result["channel_types"], [0, 0, 0, 0, 0, 0, 0, 0]) + def test_get_channel_configuration_1_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = GetChannelConfiguration_1(Address(1, 1, 48896)) + params = HoiParams().add(8, U16).add([0, 0, 0, 0, 0, 0, 0, 0], I16Array).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, GetChannelConfiguration_1.Response) + self.assertEqual(result.channels, 8) + self.assertEqual(result.channel_types, [0, 0, 0, 0, 0, 0, 0, 0]) def test_pre_initialize_smart_command(self): cmd = PreInitializeSmart(Address(1, 1, 257)) @@ -587,18 +607,38 @@ async def test_fill_by_channels_mismatched_lengths(self): backend._fill_by_channels([1, 2], [0, 1, 2], default=0) -def _mock_send_command_response(command) -> Optional[dict]: - """Return appropriate mock responses based on command type.""" +def _build_command_response(params: bytes) -> CommandResponse: + """Build a minimal CommandResponse with the given HOI params (for interpret_response tests).""" + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + return CommandResponse.from_bytes(ip.pack()) + + +def _mock_send_command_response(command): + """Return appropriate mock responses based on command type (Response instances or None).""" if isinstance(command, IsTipPresent): - return {"tip_present": [0] * 8} + return IsTipPresent.Response(tip_present=[0] * 8) if isinstance(command, IsDoorLocked): - return {"locked": True} + return IsDoorLocked.Response(locked=True) if isinstance(command, IsInitialized): - return {"initialized": True} + return IsInitialized.Response(value=True) if isinstance(command, GetChannelConfiguration_1): - return {"channels": 8, "channel_types": [0] * 8} + return GetChannelConfiguration_1.Response(channels=8, channel_types=[0] * 8) if isinstance(command, GetChannelConfiguration): - return {"enabled": [False]} + return GetChannelConfiguration.Response(enabled=[False]) return None @@ -683,7 +723,8 @@ async def test_serialize(self): serialized = backend.serialize() self.assertEqual(serialized["client_id"], 5) - self.assertIn("instrument_addresses", serialized) + self.assertIn("registry_paths", serialized) + self.assertEqual(serialized["registry_paths"], []) class TestNimbusLiquidHandling(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py index 633a6b15c01..88db123cb77 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py @@ -1,13 +1,14 @@ -"""Hamilton command architecture using new simplified TCP stack. +"""Command layer for Hamilton TCP. -This module provides the HamiltonCommand base class that uses the new refactored -architecture: Wire → HoiParams → Packets → Messages → Commands. +HamiltonCommand base: build_parameters() returns HoiParams; interpret_response() +auto-decodes success responses via nested Response dataclasses (wire-type +annotations and parse_into_struct). Wire → HoiParams → Packets → Messages → Commands. """ from __future__ import annotations import inspect -from typing import Optional +from typing import Any, Optional from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, @@ -16,6 +17,7 @@ ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import I32 class HamiltonCommand: @@ -38,7 +40,7 @@ def __init__(self, dest: Address, value: int): self.value = value def build_parameters(self) -> HoiParams: - return HoiParams().i32(self.value) + return HoiParams().add(self.value, I32) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -150,18 +152,27 @@ def build( # Build final packet return msg.build(source, sequence, harp_response_required=response_required) - def interpret_response(self, response: CommandResponse) -> Optional[dict]: - """Interpret success response. + def interpret_response(self, response: CommandResponse) -> Any: + """Interpret success response (command layer auto-decode). - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. + If the command class defines a nested Response dataclass with wire-type + annotations, decode via parse_into_struct and return a Response instance. + Otherwise fall back to parse_response_parameters (dict or None). Args: response: CommandResponse from network Returns: - Dictionary with parsed response data, or None if no data to extract + Command.Response instance, dict, or None """ + cls = type(self) + if hasattr(cls, "Response") and response.hoi.params: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParamsParser, + parse_into_struct, + ) + + return parse_into_struct(HoiParamsParser(response.hoi.params), cls.Response) return self.parse_response_parameters(response.hoi.params) @classmethod diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index d1649981d1e..0a66bc7447a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -9,14 +9,20 @@ import logging from dataclasses import dataclass, field -from typing import Any, Dict, List +from typing import Annotated, Any, Dict, List from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + CountedFlatArray, HamiltonDataType, - HamiltonProtocol, + I32, + Str, + U8, + U16, + U32, ) logger = logging.getLogger(__name__) @@ -189,6 +195,7 @@ class ObjectInfo: method_count: int subobject_count: int address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) @dataclass @@ -300,6 +307,54 @@ def get_struct_string(self) -> str: return f"struct {self.name} {{\n {fields_str}\n}}" +# ============================================================================ +# WIRE STRUCTS FOR INTROSPECTION RESPONSES +# ============================================================================ + + +@dataclass(frozen=True) +class _InterfaceRowWire: + """One row of GetInterfaces response: interface_id, name, version.""" + + interface_id: I32 + name: Str + version: I32 + + +@dataclass(frozen=True) +class _EnumValueWire: + """One enum value: name, value.""" + + value_name: Str + value_value: I32 + + +@dataclass(frozen=True) +class _EnumRowWire: + """One row of GetEnums response: enum_id, name, values (counted flat array).""" + + enum_id: I32 + name: Str + values: Annotated[list[_EnumValueWire], CountedFlatArray()] + + +@dataclass(frozen=True) +class _StructFieldWire: + """One struct field: field_name, field_type.""" + + field_name: Str + field_type: I32 + + +@dataclass(frozen=True) +class _StructRowWire: + """One row of GetStructs response: struct_id, name, fields (counted flat array).""" + + struct_id: I32 + name: Str + fields: Annotated[list[_StructFieldWire], CountedFlatArray()] + + # ============================================================================ # INTROSPECTION COMMAND CLASSES # ============================================================================ @@ -316,23 +371,12 @@ class GetObjectCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_object response.""" - # Parse HOI2 DataFragments - parser = HoiParamsParser(data) - - _, name = parser.parse_next() - _, version = parser.parse_next() - _, method_count = parser.parse_next() - _, subobject_count = parser.parse_next() - - return { - "name": name, - "version": version, - "method_count": method_count, - "subobject_count": subobject_count, - } + @dataclass(frozen=True) + class Response: + name: Str + version: Str + method_count: I32 + subobject_count: I32 class GetMethodCommand(HamiltonCommand): @@ -349,7 +393,7 @@ def __init__(self, object_address: Address, method_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_method command.""" - return HoiParams().u32(self.method_index) + return HoiParams().add(self.method_index, U32) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -435,18 +479,13 @@ def __init__(self, object_address: Address, subobject_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_subobject_address command.""" - return HoiParams().u16(self.subobject_index) # Use u16, not u32 + return HoiParams().add(self.subobject_index, U16) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_subobject_address response.""" - parser = HoiParamsParser(data) - - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() - - return {"address": Address(module_id, node_id, object_id)} + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 class GetInterfacesCommand(HamiltonCommand): @@ -460,21 +499,9 @@ class GetInterfacesCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_interfaces response.""" - parser = HoiParamsParser(data) - - interfaces = [] - _, interface_count = parser.parse_next() - - for _ in range(interface_count): - _, interface_id = parser.parse_next() - _, name = parser.parse_next() - _, version = parser.parse_next() - interfaces.append({"interface_id": interface_id, "name": name, "version": version}) - - return {"interfaces": interfaces} + @dataclass(frozen=True) + class Response: + interfaces: Annotated[list[_InterfaceRowWire], CountedFlatArray()] class GetEnumsCommand(HamiltonCommand): @@ -491,31 +518,11 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_enums command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_enums response.""" - parser = HoiParamsParser(data) + return HoiParams().add(self.target_interface_id, U8) - enums = [] - _, enum_count = parser.parse_next() - - for _ in range(enum_count): - _, enum_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse enum values - _, value_count = parser.parse_next() - values = {} - for _ in range(value_count): - _, value_name = parser.parse_next() - _, value_value = parser.parse_next() - values[value_name] = value_value - - enums.append({"enum_id": enum_id, "name": name, "values": values}) - - return {"enums": enums} + @dataclass(frozen=True) + class Response: + enums: Annotated[list[_EnumRowWire], CountedFlatArray()] class GetStructsCommand(HamiltonCommand): @@ -532,31 +539,11 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_structs command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_structs response.""" - parser = HoiParamsParser(data) - - structs = [] - _, struct_count = parser.parse_next() - - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse struct fields - _, field_count = parser.parse_next() - fields = {} - for _ in range(field_count): - _, field_name = parser.parse_next() - _, field_type = parser.parse_next() - fields[field_name] = field_type + return HoiParams().add(self.target_interface_id, U8) - structs.append({"struct_id": struct_id, "name": name, "fields": fields}) - - return {"structs": structs} + @dataclass(frozen=True) + class Response: + structs: Annotated[list[_StructRowWire], CountedFlatArray()] # ============================================================================ @@ -586,12 +573,14 @@ async def get_object(self, address: Address) -> ObjectInfo: """ command = GetObjectCommand(address) response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetObjectCommand returned None") return ObjectInfo( - name=response["name"], - version=response["version"], - method_count=response["method_count"], - subobject_count=response["subobject_count"], + name=response.name, + version=response.version, + method_count=int(response.method_count), + subobject_count=int(response.subobject_count), address=address, ) @@ -631,10 +620,10 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> """ command = GetSubobjectAddressCommand(address, subobject_index) response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetSubobjectAddressCommand returned None") - # Type: ignore needed because response dict is typed as dict[str, Any] - # but we know 'address' key contains Address object - return response["address"] # type: ignore[no-any-return, return-value] + return Address(response.module_id, response.node_id, response.object_id) async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: """Get available interfaces. @@ -647,12 +636,16 @@ async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: """ command = GetInterfacesCommand(address) response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") return [ InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + interface_id=int(iface.interface_id), + name=iface.name, + version=str(iface.version), ) - for iface in response["interfaces"] + for iface in response.interfaces ] async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: @@ -667,10 +660,16 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] """ command = GetEnumsCommand(address, interface_id) response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetEnumsCommand returned None") return [ - EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) - for enum_def in response["enums"] + EnumInfo( + enum_id=int(enum_def.enum_id), + name=enum_def.name, + values={v.value_name: int(v.value_value) for v in enum_def.values}, + ) + for enum_def in response.enums ] async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: @@ -685,12 +684,16 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI """ command = GetStructsCommand(address, interface_id) response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetStructsCommand returned None") return [ StructInfo( - struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] + struct_id=int(struct_def.struct_id), + name=struct_def.name, + fields={f.field_name: int(f.field_type) for f in struct_def.fields}, ) - for struct_def in response["structs"] + for struct_def in response.structs ] async def get_all_methods(self, address: Address) -> List[MethodInfo]: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index 986e6e6326c..f3b5f9a498a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -1,41 +1,21 @@ -"""High-level Hamilton message builders and response parsers. - -This module provides user-facing message builders and their corresponding -response parsers. Each message type is paired with its response type: - -Request Builders: -- InitMessage: Builds IP[Connection] for initialization -- RegistrationMessage: Builds IP[HARP[Registration]] for discovery -- CommandMessage: Builds IP[HARP[HOI]] for method calls - -Response Parsers: -- InitResponse: Parses initialization responses -- RegistrationResponse: Parses registration responses -- CommandResponse: Parses command responses - -This pairing creates symmetry and makes correlation explicit. - -Architectural Note: -Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer -in the Hamilton protocol architecture (per documented architecture), but is -implemented here for efficiency since it's exclusively used by HOI messages. -This preserves the conceptual separation while optimizing implementation. - -Example: - # Build and send - msg = CommandMessage(dest, interface_id=0, method_id=42) - msg.add_i32(100) - packet_bytes = msg.build(src, seq=1) - - # Parse response - response = CommandResponse.from_bytes(received_bytes) - params = response.hoi.params +"""Framing and protocol message layer for Hamilton TCP. + +HoiParams is a fragment accumulator with add(value, wire_type) and +from_struct(obj); it has no type-specific encoding logic and delegates all +encoding to WireType.encode_into in wire_types. HoiParamsParser is a thin +cursor over sequential DataFragments; it reads [type_id:1][flags:1][length:2] +[data:N] headers and delegates value decoding to wire_types.decode_fragment(). +parse_into_struct() is the dataclass codec that uses WireType annotations to +decode fragment sequences into typed instances. + +Also: message builders (CommandMessage, InitMessage, RegistrationMessage) and +response parsers (CommandResponse, InitResponse, RegistrationResponse). """ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from dataclasses import dataclass, fields as dc_fields +from typing import Any, get_args, get_origin, get_type_hints from pylabrobot.io.binary import Reader, Writer from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( @@ -46,10 +26,13 @@ RegistrationPacket, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, HarpTransportableProtocol, RegistrationOptionType, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + HamiltonDataType, + decode_fragment, +) # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol @@ -75,9 +58,9 @@ class HoiParams: [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] params = (HoiParams() - .i32(100) - .string("test") - .u32_array([1, 2, 3]) + .add(100, I32) + .add("test", Str) + .add([1, 2, 3], U32Array) .build()) """ @@ -98,246 +81,14 @@ def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams self._fragments.append(fragment) return self - # Scalar integer types - def i8(self, value: int) -> "HoiParams": - """Add signed 8-bit integer parameter.""" - data = Writer().i8(value).finish() - return self._add_fragment(HamiltonDataType.I8, data) - - def i16(self, value: int) -> "HoiParams": - """Add signed 16-bit integer parameter.""" - data = Writer().i16(value).finish() - return self._add_fragment(HamiltonDataType.I16, data) - - def i32(self, value: int) -> "HoiParams": - """Add signed 32-bit integer parameter.""" - data = Writer().i32(value).finish() - return self._add_fragment(HamiltonDataType.I32, data) - - def i64(self, value: int) -> "HoiParams": - """Add signed 64-bit integer parameter.""" - data = Writer().i64(value).finish() - return self._add_fragment(HamiltonDataType.I64, data) - - def u8(self, value: int, padded: bool = False) -> "HoiParams": - """Add unsigned 8-bit integer parameter. - - Args: - value: Value to encode. - padded: If True, use flags=0x01 and append pad byte (Prep compatibility). - """ - data = Writer().u8(value).finish() - if padded: - data += b"\x00" - return self._add_fragment(HamiltonDataType.U8, data, flags=0x01) - return self._add_fragment(HamiltonDataType.U8, data) - - def u16(self, value: int) -> "HoiParams": - """Add unsigned 16-bit integer parameter.""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.U16, data) - - def u32(self, value: int) -> "HoiParams": - """Add unsigned 32-bit integer parameter.""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.U32, data) - - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) - - # Floating-point types - def f32(self, value: float) -> "HoiParams": - """Add 32-bit float parameter.""" - data = Writer().f32(value).finish() - return self._add_fragment(HamiltonDataType.F32, data) - - def f64(self, value: float) -> "HoiParams": - """Add 64-bit double parameter.""" - data = Writer().f64(value).finish() - return self._add_fragment(HamiltonDataType.F64, data) - - # String and bool - def string(self, value: str) -> "HoiParams": - """Add null-terminated string parameter.""" - data = Writer().string(value).finish() - return self._add_fragment(HamiltonDataType.STRING, data) - - def bool_value(self, value: bool, padded: bool = False) -> "HoiParams": - """Add boolean parameter. - - Args: - value: The boolean value. - padded: If True, use Prep-compatible encoding: flags=0x01 and append pad - byte [type=23][flags=1][len=2][0x00 or 0x01][0x00]. Default False for - Nimbus compatibility. - """ - data = Writer().u8(1 if value else 0).finish() - if padded: - data += b"\x00" - return self._add_fragment(HamiltonDataType.BOOL, data, flags=0x01) - return self._add_fragment(HamiltonDataType.BOOL, data) - - # Enum and structure types (Prep) - def enum_value(self, value: int) -> "HoiParams": - """Add enum parameter (encoded as u32, type_id=32).""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.ENUM, data) - - def hc_result(self, value: int) -> "HoiParams": - """Add HC_RESULT parameter (same wire format as u16, type_id=33).""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.HC_RESULT, data) - - def enum_array(self, values: list[int]) -> "HoiParams": - """Add array of enums (each u32, type_id=35).""" - writer = Writer() - for val in values: - writer.u32(val) - return self._add_fragment(HamiltonDataType.ENUM_ARRAY, writer.finish()) - - def structure(self, nested: "HoiParams") -> "HoiParams": - """Add nested structure (type_id=30). Data is concatenated DataFragments.""" - return self._add_fragment(HamiltonDataType.STRUCTURE, nested.build()) - - def structure_from_bytes(self, data: bytes) -> "HoiParams": - """Add structure (type_id=30) from raw concatenated DataFragment bytes. - - Used when building from Prep dataclasses that still use .encode(). - """ - return self._add_fragment(HamiltonDataType.STRUCTURE, data) + def add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. - def structure_array(self, elements: list["HoiParams"]) -> "HoiParams": - """Add array of structures (type_id=31). Each element wrapped as Structure fragment.""" - inner = b"" - for elem in elements: - payload = elem.build() - inner += Writer().u8(HamiltonDataType.STRUCTURE).u8(0).u16(len(payload)).raw_bytes(payload).finish() - return self._add_fragment(HamiltonDataType.STRUCTURE_ARRAY, inner) - - # Array types - def i8_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + wire_type may be a WireType instance or an Annotated alias (e.g. I32, Str). """ - writer = Writer() - for val in values: - writer.i8(val) - return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) - - def i16_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i16(val) - return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) - - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i32(val) - return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) - - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i64(val) - return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) - - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u8(val) - return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) - - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u16(val) - return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) - - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u32(val) - return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) - - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u64(val) - return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) - - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f32(val) - return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) - - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f64(val) - return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) - - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - - Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). - """ - writer = Writer() - for val in values: - writer.u8(1 if val else 0) - return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) - - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. - - Format: [count:4][str0\0][str1\0]... - """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + if hasattr(wire_type, "__metadata__"): + wire_type = wire_type.__metadata__[0] + return wire_type.encode_into(value, self) # ------------------------------------------------------------------ # Generic dataclass serialiser (wire_types.py Annotated metadata) @@ -378,9 +129,10 @@ def count(self) -> int: class HoiParamsParser: - """Parser for HOI DataFragment parameters. + """Cursor over sequential DataFragments in an HOI payload. - Parses DataFragment-wrapped values from HOI response payloads. + Reads [type_id:1][flags:1][length:2][data:N] headers and delegates + value decoding to the unified codec in wire_types.decode_fragment(). """ def __init__(self, data: bytes): @@ -388,183 +140,89 @@ def __init__(self, data: bytes): self._offset = 0 def parse_next(self) -> tuple[int, Any]: - """Parse the next DataFragment and return (type_id, value). - - Returns: - Tuple of (type_id, parsed_value) - - Raises: - ValueError: If data is malformed or insufficient - """ if self._offset + 4 > len(self._data): - raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") - - # Parse DataFragment header - reader = Reader(self._data[self._offset :]) - type_id = reader.u8() - _flags = reader.u8() # Read but unused - length = reader.u16() - - data_start = self._offset + 4 - data_end = data_start + length - - if data_end > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): raise ValueError( - f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" ) - - # Extract data payload - fragment_data = self._data[data_start:data_end] - value = self._parse_value(type_id, fragment_data) - - # Move offset past this fragment - self._offset = data_end - - return (type_id, value) - - def _parse_value(self, type_id: int, data: bytes) -> Any: - """Parse value based on type_id using dispatch table.""" - reader = Reader(data) - - # Dispatch table for scalar types - scalar_parsers = { - HamiltonDataType.I8: reader.i8, - HamiltonDataType.I16: reader.i16, - HamiltonDataType.I32: reader.i32, - HamiltonDataType.I64: reader.i64, - HamiltonDataType.U8: reader.u8, - HamiltonDataType.U16: reader.u16, - HamiltonDataType.U32: reader.u32, - HamiltonDataType.U64: reader.u64, - HamiltonDataType.F32: reader.f32, - HamiltonDataType.F64: reader.f64, - HamiltonDataType.STRING: reader.string, - } - - # Check scalar types first - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in scalar_parsers: - return scalar_parsers[data_type]() - except ValueError: - pass # Not a valid enum value, continue to other checks - - # Special case: bool (may have padding byte; we read first byte only) - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # Enum and structure types - if type_id == HamiltonDataType.ENUM: - return reader.u32() - if type_id == HamiltonDataType.HC_RESULT: - return reader.u16() - if type_id == HamiltonDataType.STRUCTURE: - return data # Raw bytes; caller can HoiParamsParser(data).parse_all() - if type_id == HamiltonDataType.ENUM_ARRAY: - count = len(data) // 4 - return [reader.u32() for _ in range(count)] - if type_id == HamiltonDataType.STRUCTURE_ARRAY: - # Data is concatenated Structure fragments: [type=30][flags][len:2][payload]... - result = [] - r = Reader(data) - while r.offset() + 4 <= len(data): - frag_type = r.u8() - _frag_flags = r.u8() - frag_len = r.u16() - if frag_type != HamiltonDataType.STRUCTURE: - raise ValueError(f"Expected STRUCTURE fragment in STRUCTURE_ARRAY, got type {frag_type}") - if r.offset() + frag_len > len(data): - raise ValueError("STRUCTURE_ARRAY element extends beyond buffer") - result.append(r.raw_bytes(frag_len)) - return result - - # Dispatch table for array element parsers - array_element_parsers = { - HamiltonDataType.I8_ARRAY: reader.i8, - HamiltonDataType.I16_ARRAY: reader.i16, - HamiltonDataType.I32_ARRAY: reader.i32, - HamiltonDataType.I64_ARRAY: reader.i64, - HamiltonDataType.U8_ARRAY: reader.u8, - HamiltonDataType.U16_ARRAY: reader.u16, - HamiltonDataType.U32_ARRAY: reader.u32, - HamiltonDataType.U64_ARRAY: reader.u64, - HamiltonDataType.F32_ARRAY: reader.f32, - HamiltonDataType.F64_ARRAY: reader.f64, - HamiltonDataType.STRING_ARRAY: reader.string, - } - - # Handle arrays - # Arrays don't have a count prefix - count is derived from DataFragment length - # Calculate element size based on type - element_sizes = { - HamiltonDataType.I8_ARRAY: 1, - HamiltonDataType.I16_ARRAY: 2, - HamiltonDataType.I32_ARRAY: 4, - HamiltonDataType.I64_ARRAY: 8, - HamiltonDataType.U8_ARRAY: 1, - HamiltonDataType.U16_ARRAY: 2, - HamiltonDataType.U32_ARRAY: 4, - HamiltonDataType.U64_ARRAY: 8, - HamiltonDataType.F32_ARRAY: 4, - HamiltonDataType.F64_ARRAY: 8, - HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately - } - - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in array_element_parsers: - element_size = element_sizes.get(data_type) - if element_size is not None: - # Fixed-size elements: calculate count from data length - count = len(data) // element_size - return [array_element_parsers[data_type]() for _ in range(count)] - elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: null-terminated strings concatenated, no count prefix - # Parse by splitting on null bytes - strings = [] - current_string = bytearray() - for byte in data: - if byte == 0: - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - current_string = bytearray() - else: - current_string.append(byte) - # Handle case where last string doesn't end with null (shouldn't happen, but be safe) - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - return strings - except ValueError: - # Not a valid enum value, continue to other checks - # This shouldn't happen for valid Hamilton types, but we continue anyway - pass - - # Special case: bool array (1 byte per element) - if type_id == HamiltonDataType.BOOL_ARRAY: - count = len(data) // 1 # Each bool is 1 byte - return [reader.u8() == 1 for _ in range(count)] - - # Unknown type - raise ValueError(f"Unknown or unsupported type_id: {type_id}") + data = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, decode_fragment(type_id, data) def has_remaining(self) -> bool: - """Check if there are more DataFragments to parse.""" return self._offset < len(self._data) def parse_all(self) -> list[tuple[int, Any]]: - """Parse all remaining DataFragments. - - Returns: - List of (type_id, value) tuples - """ results = [] while self.has_remaining(): results.append(self.parse_next()) return results +def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: + """Decode a sequence of DataFragments into a dataclass instance using its wire-type annotations. + + Mirrors HoiParams.from_struct: walks the same Annotated field metadata and, for each field in + order, consumes one fragment (via parser.parse_next()). Scalars/arrays/string yield the value + as returned by the parser; Struct recurses on the payload bytes; StructArray yields a list of + recursively decoded instances. + + Args: + parser: Parser positioned at the start of the fragment sequence (e.g. response payload). + cls: Dataclass type whose fields are annotated with wire_types (F32, Struct(), etc.). + + Returns: + An instance of cls with fields populated from the parsed fragments. + + Raises: + ValueError: If data is malformed or insufficient. + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + CountedFlatArray, + Struct, + StructArray, + WireType, + ) + + hints = get_type_hints(cls, include_extras=True) + values: dict[str, Any] = {} + for f in dc_fields(cls): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + + if isinstance(meta, CountedFlatArray): + _, count = parser.parse_next() + element_type = get_args(get_args(ann)[0])[0] + values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] + continue + + type_id, value = parser.parse_next() + + if isinstance(meta, Struct): + inner_type = get_args(ann)[0] + value = parse_into_struct(HoiParamsParser(value), inner_type) + elif isinstance(meta, StructArray): + inner_ann = get_args(ann)[0] + if get_origin(inner_ann) is list: + element_type = get_args(inner_ann)[0] + else: + element_type = inner_ann + value = [parse_into_struct(HoiParamsParser(p), element_type) for p in value] + # else: decode_fragment() already returned correctly-typed value + + values[f.name] = value + + return cls(**values) + + # ============================================================================ # MESSAGE BUILDERS # ============================================================================ diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py index a02c6b01dc2..60b89b61dee 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py @@ -1,7 +1,8 @@ -"""Hamilton TCP protocol constants and enumerations. +"""Transport-level protocol constants only. -This module contains all protocol-level constants, enumerations, and type definitions -used throughout the Hamilton TCP communication stack. +HamiltonProtocol, Hoi2Action, HarpTransportableProtocol, RegistrationActionCode, +RegistrationOptionType, HoiRequestId. DataFragment type IDs (I8, I32, STRUCTURE, +etc.) are defined in wire_types.HamiltonDataType. """ from __future__ import annotations @@ -124,56 +125,6 @@ class RegistrationOptionType(IntEnum): HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) -class HamiltonDataType(IntEnum): - """Hamilton parameter data types for wire encoding in DataFragments. - - These constants represent the type identifiers used in Hamilton DataFragments - for HOI2 command parameters. Each type ID corresponds to a specific data format - and encoding scheme used on the wire. - - From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. - """ - - # Scalar integer types - I8 = 1 - I16 = 2 - I32 = 3 - U8 = 4 - U16 = 5 - U32 = 6 - I64 = 36 - U64 = 37 - - # Floating-point types - F32 = 40 - F64 = 41 - - # String and boolean - STRING = 15 - BOOL = 23 - - # Structure and enum types (Prep and introspection) - STRUCTURE = 30 - STRUCTURE_ARRAY = 31 - ENUM = 32 - HC_RESULT = 33 # Same wire format as U16, used for error codes - ENUM_ARRAY = 35 - - # Array types - U8_ARRAY = 22 - I8_ARRAY = 24 - I16_ARRAY = 25 - U16_ARRAY = 26 - I32_ARRAY = 27 - U32_ARRAY = 28 - BOOL_ARRAY = 29 - STRING_ARRAY = 34 - I64_ARRAY = 38 - U64_ARRAY = 39 - F32_ARRAY = 42 - F64_ARRAY = 43 - - class HoiRequestId(IntEnum): """Request types for HarpProtocolRequest (byte 3 in command_data). diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index b83ad729407..681effd71e9 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -4,7 +4,10 @@ and command classes in the Hamilton TCP protocol stack. """ +import struct import unittest +from dataclasses import dataclass +from typing import Annotated from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( @@ -16,6 +19,7 @@ InitResponse, RegistrationMessage, RegistrationResponse, + parse_into_struct, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( Address, @@ -27,12 +31,33 @@ encode_version_byte, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, HamiltonProtocol, Hoi2Action, RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + Bool, + BoolArray, + CountedFlatArray, + F32, + F32Array, + F64, + HamiltonDataType, + I8, + I16, + I32, + I32Array, + I64, + Str, + StrArray, + U8, + U16, + U16Array, + U32, + U64, + decode_fragment, +) class TestVersionByte(unittest.TestCase): @@ -354,7 +379,7 @@ def test_empty_params(self): self.assertEqual(HoiParams().count(), 0) def test_i8(self): - params = HoiParams().i8(-128).build() + params = HoiParams().add(-128, I8).build() # DataFragment: [type=1][flags=0][length=1][data=-128] self.assertEqual(params[0], HamiltonDataType.I8) self.assertEqual(params[1], 0) # flags @@ -362,98 +387,98 @@ def test_i8(self): self.assertEqual(params[4], 0x80) # -128 as signed byte def test_i16(self): - params = HoiParams().i16(-1000).build() + params = HoiParams().add(-1000, I16).build() self.assertEqual(params[0], HamiltonDataType.I16) self.assertEqual(params[2:4], b"\x02\x00") # length = 2 def test_i32(self): - params = HoiParams().i32(100000).build() + params = HoiParams().add(100000, I32).build() self.assertEqual(params[0], HamiltonDataType.I32) self.assertEqual(params[2:4], b"\x04\x00") # length = 4 def test_i64(self): - params = HoiParams().i64(2**40).build() + params = HoiParams().add(2**40, I64).build() self.assertEqual(params[0], HamiltonDataType.I64) self.assertEqual(params[2:4], b"\x08\x00") # length = 8 def test_u8(self): - params = HoiParams().u8(255).build() + params = HoiParams().add(255, U8).build() self.assertEqual(params[0], HamiltonDataType.U8) self.assertEqual(params[4], 255) def test_u16(self): - params = HoiParams().u16(65535).build() + params = HoiParams().add(65535, U16).build() self.assertEqual(params[0], HamiltonDataType.U16) self.assertEqual(params[4:6], b"\xFF\xFF") def test_u32(self): - params = HoiParams().u32(0xDEADBEEF).build() + params = HoiParams().add(0xDEADBEEF, U32).build() self.assertEqual(params[0], HamiltonDataType.U32) self.assertEqual(params[4:8], b"\xEF\xBE\xAD\xDE") def test_u64(self): - params = HoiParams().u64(0xDEADBEEFCAFEBABE).build() + params = HoiParams().add(0xDEADBEEFCAFEBABE, U64).build() self.assertEqual(params[0], HamiltonDataType.U64) def test_f32(self): - params = HoiParams().f32(3.14).build() + params = HoiParams().add(3.14, F32).build() self.assertEqual(params[0], HamiltonDataType.F32) self.assertEqual(params[2:4], b"\x04\x00") def test_f64(self): - params = HoiParams().f64(3.14159265358979).build() + params = HoiParams().add(3.14159265358979, F64).build() self.assertEqual(params[0], HamiltonDataType.F64) self.assertEqual(params[2:4], b"\x08\x00") def test_string(self): - params = HoiParams().string("test").build() + params = HoiParams().add("test", Str).build() self.assertEqual(params[0], HamiltonDataType.STRING) self.assertEqual(params[2:4], b"\x05\x00") # length = 5 (including null) self.assertEqual(params[4:9], b"test\x00") def test_bool_true(self): - params = HoiParams().bool_value(True).build() + params = HoiParams().add(True, Bool).build() self.assertEqual(params[0], HamiltonDataType.BOOL) self.assertEqual(params[4], 1) def test_bool_false(self): - params = HoiParams().bool_value(False).build() + params = HoiParams().add(False, Bool).build() self.assertEqual(params[0], HamiltonDataType.BOOL) self.assertEqual(params[4], 0) def test_i32_array(self): - params = HoiParams().i32_array([1, 2, 3]).build() + params = HoiParams().add([1, 2, 3], I32Array).build() self.assertEqual(params[0], HamiltonDataType.I32_ARRAY) self.assertEqual(params[2:4], b"\x0C\x00") # length = 12 (3 * 4) def test_u16_array(self): - params = HoiParams().u16_array([100, 200, 300]).build() + params = HoiParams().add([100, 200, 300], U16Array).build() self.assertEqual(params[0], HamiltonDataType.U16_ARRAY) self.assertEqual(params[2:4], b"\x06\x00") # length = 6 (3 * 2) def test_f32_array(self): - params = HoiParams().f32_array([1.0, 2.0, 3.0]).build() + params = HoiParams().add([1.0, 2.0, 3.0], F32Array).build() self.assertEqual(params[0], HamiltonDataType.F32_ARRAY) self.assertEqual(params[2:4], b"\x0C\x00") # length = 12 (3 * 4) def test_bool_array(self): - params = HoiParams().bool_array([True, False, True]).build() + params = HoiParams().add([True, False, True], BoolArray).build() self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) self.assertEqual(params[1], 0x01) # flags = 0x01 for bool arrays self.assertEqual(params[2:4], b"\x03\x00") # length = 3 self.assertEqual(params[4:7], b"\x01\x00\x01") def test_string_array(self): - params = HoiParams().string_array(["a", "bc"]).build() + params = HoiParams().add(["a", "bc"], StrArray).build() self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) # String arrays have u32 count prefix self.assertEqual(params[4:8], b"\x02\x00\x00\x00") # count = 2 def test_method_chaining(self): - self.assertEqual(HoiParams().i32(1).string("test").bool_value(True).count(), 3) + self.assertEqual(HoiParams().add(1, I32).add("test", Str).add(True, Bool).count(), 3) def test_count(self): - builder = HoiParams().i32(1).i32(2).i32(3) + builder = HoiParams().add(1, I32).add(2, I32).add(3, I32) self.assertEqual(builder.count(), 3) @@ -461,55 +486,55 @@ class TestHoiParamsParser(unittest.TestCase): """Tests for HoiParamsParser.""" def test_parse_i32(self): - params = HoiParams().i32(12345).build() + params = HoiParams().add(12345, I32).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.I32) self.assertEqual(value, 12345) def test_parse_negative_i32(self): - params = HoiParams().i32(-12345).build() + params = HoiParams().add(-12345, I32).build() parser = HoiParamsParser(params) _, value = parser.parse_next() self.assertEqual(value, -12345) def test_parse_string(self): - params = HoiParams().string("hello").build() + params = HoiParams().add("hello", Str).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.STRING) self.assertEqual(value, "hello") def test_parse_bool(self): - params = HoiParams().bool_value(True).build() + params = HoiParams().add(True, Bool).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.BOOL) self.assertEqual(value, True) def test_parse_i32_array(self): - params = HoiParams().i32_array([10, 20, 30]).build() + params = HoiParams().add([10, 20, 30], I32Array).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.I32_ARRAY) self.assertEqual(value, [10, 20, 30]) def test_parse_u16_array(self): - params = HoiParams().u16_array([100, 200, 300]).build() + params = HoiParams().add([100, 200, 300], U16Array).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.U16_ARRAY) self.assertEqual(value, [100, 200, 300]) def test_parse_bool_array(self): - params = HoiParams().bool_array([True, False, True, False]).build() + params = HoiParams().add([True, False, True, False], BoolArray).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.BOOL_ARRAY) self.assertEqual(value, [True, False, True, False]) def test_parse_multiple(self): - params = HoiParams().i32(100).string("test").bool_value(False).build() + params = HoiParams().add(100, I32).add("test", Str).add(False, Bool).build() parser = HoiParamsParser(params) _, v1 = parser.parse_next() @@ -521,7 +546,7 @@ def test_parse_multiple(self): self.assertEqual(v3, False) def test_parse_all(self): - params = HoiParams().i32(1).i32(2).i32(3).build() + params = HoiParams().add(1, I32).add(2, I32).add(3, I32).build() parser = HoiParamsParser(params) results = parser.parse_all() @@ -529,7 +554,7 @@ def test_parse_all(self): self.assertEqual([v for _, v in results], [1, 2, 3]) def test_has_remaining(self): - params = HoiParams().i32(1).build() + params = HoiParams().add(1, I32).build() parser = HoiParamsParser(params) self.assertTrue(parser.has_remaining()) parser.parse_next() @@ -551,18 +576,18 @@ def test_roundtrip_all_types(self): bool_val = True builder = HoiParams() - builder.i8(i8_val) - builder.i16(i16_val) - builder.i32(i32_val) - builder.i64(i64_val) - builder.u8(u8_val) - builder.u16(u16_val) - builder.u32(u32_val) - builder.u64(u64_val) - builder.f32(f32_val) - builder.f64(f64_val) - builder.string(string_val) - builder.bool_value(bool_val) + builder.add(i8_val, I8) + builder.add(i16_val, I16) + builder.add(i32_val, I32) + builder.add(i64_val, I64) + builder.add(u8_val, U8) + builder.add(u16_val, U16) + builder.add(u32_val, U32) + builder.add(u64_val, U64) + builder.add(f32_val, F32) + builder.add(f64_val, F64) + builder.add(string_val, Str) + builder.add(bool_val, Bool) params = builder.build() parser = HoiParamsParser(params) @@ -588,7 +613,7 @@ class TestCommandMessage(unittest.TestCase): def test_build_simple_command(self): dest = Address(1, 1, 257) - params = HoiParams().i32(100) + params = HoiParams().add(100, I32) msg = CommandMessage(dest=dest, interface_id=1, method_id=4, params=params) src = Address(2, 1, 65535) @@ -917,7 +942,7 @@ class TestCommand(HamiltonCommand): command_id = 4 def build_parameters(self): - return HoiParams().i32(100) + return HoiParams().add(100, I32) cmd = TestCommand(Address(1, 1, 257)) cmd.source_address = Address(2, 1, 65535) @@ -954,6 +979,101 @@ def __init__(self, dest: Address, value: int, name: str): self.assertNotIn("self", log_params) +class TestInterpretResponseAutoDecode(unittest.TestCase): + """Tests for interpret_response auto-decode when command has Response class.""" + + def test_auto_decode_with_response_class(self): + """Command with nested Response decodes via parse_into_struct.""" + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import I64 + + class CommandWithResponse(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 0 + + @dataclass(frozen=True) + class Response: + value: I64 + + cmd = CommandWithResponse(Address(0, 0, 0)) + params = HoiParams().add(42, I64).build() + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertIsInstance(result, CommandWithResponse.Response) + self.assertEqual(result.value, 42) + + def test_auto_decode_fallback_no_response_class(self): + """Command without Response returns None when params empty.""" + class CommandNoResponse(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + + cmd = CommandNoResponse(Address(0, 0, 0)) + hoi = HoiPacket(interface_id=0, action_code=4, action_id=1, params=b"") + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertIsNone(result) + + def test_auto_decode_fallback_parse_response_parameters(self): + """Command with parse_response_parameters override but no Response uses override.""" + class CommandWithOverride(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 0 + + @classmethod + def parse_response_parameters(cls, data): + parser = HoiParamsParser(data) + _, v = parser.parse_next() + return {"value": v} + + cmd = CommandWithOverride(Address(0, 0, 0)) + params = HoiParams().add(100, I32).build() + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertEqual(result, {"value": 100}) + + class TestProtocolEnums(unittest.TestCase): """Tests for protocol enum values.""" @@ -983,5 +1103,247 @@ def test_hamilton_data_type_values(self): self.assertEqual(HamiltonDataType.I32_ARRAY, 27) +class TestDecodeFragment(unittest.TestCase): + """Tests for decode_fragment() and correct Python types.""" + + def test_decode_i32(self): + data = struct.pack(" HoiParams: raise NotImplementedError + def decode_from(self, data: bytes) -> Any: + raise NotImplementedError + class Scalar(WireType): """Fixed-size scalar encoded via ``struct.pack(fmt, value)``. @@ -66,6 +110,18 @@ def encode_into(self, value, params: HoiParams) -> HoiParams: data += b"\x00" return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) + def decode_from(self, data: bytes) -> Any: + size = _struct.calcsize(self.fmt) + val = _struct.unpack(self.fmt, data[:size])[0] + if self.type_id == HamiltonDataType.BOOL: + return bool(val) + if self.type_id in ( + HamiltonDataType.F32, + HamiltonDataType.F64, + ): + return float(val) + return int(val) + class Array(WireType): """Homogeneous array of packed scalars (no length prefix on the wire).""" @@ -78,7 +134,16 @@ def __init__(self, type_id: int, element_fmt: str): def encode_into(self, value, params: HoiParams) -> HoiParams: data = _struct.pack(f"{len(value)}{self.element_fmt}", *value) - return params._add_fragment(self.type_id, data) + flags = 0x01 if self.type_id == HamiltonDataType.BOOL_ARRAY else 0 + return params._add_fragment(self.type_id, data, flags) + + def decode_from(self, data: bytes) -> Any: + el_size = _struct.calcsize(self.element_fmt) + count = len(data) // el_size + values = _struct.unpack(f"{count}{self.element_fmt}", data[: count * el_size]) + if self.type_id == HamiltonDataType.BOOL_ARRAY: + return [bool(v) for v in values] + return list(values) class Struct(WireType): @@ -92,7 +157,10 @@ def __init__(self): def encode_into(self, value, params: HoiParams) -> HoiParams: from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP - return params.structure(HP.from_struct(value)) + return params._add_fragment(self.type_id, HP.from_struct(value).build()) + + def decode_from(self, data: bytes) -> Any: + return data class StructArray(WireType): @@ -106,7 +174,41 @@ def __init__(self): def encode_into(self, value, params: HoiParams) -> HoiParams: from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP - return params.structure_array([HP.from_struct(v) for v in value]) + inner = b"" + for v in value: + payload = HP.from_struct(v).build() + inner += _struct.pack(" Any: + # Parse concatenated Structure sub-fragments: [type_id:1][flags:1][length:2][data:N] + out: list[bytes] = [] + off = 0 + while off + 4 <= len(data): + type_id = data[off] + length = int.from_bytes(data[off + 2 : off + 4], "little") + off += 4 + if off + length > len(data): + break + if type_id == HamiltonDataType.STRUCTURE: + out.append(data[off : off + length]) + off += length + return out + + +class CountedFlatArray(WireType): + """Count-prefix array where elements share the caller's parser stream. + + Decode-only (introspection protocol uses this; domain commands use StructArray). + """ + + __slots__ = () + + def __init__(self): + super().__init__(type_id=-1) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError("CountedFlatArray is decode-only (introspection protocol)") class StringType(WireType): @@ -118,7 +220,40 @@ def __init__(self): super().__init__(HamiltonDataType.STRING) def encode_into(self, value, params: HoiParams) -> HoiParams: - return params.string(value) + data = value.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + return data.rstrip(b"\x00").decode("ascii") + + +class StringArrayType(WireType): + """Count-prefixed array of null-terminated strings (type_id=34).""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(" Any: + if len(data) < 4: + return [] + count = _struct.unpack(" HoiParams: F64Array = Annotated[list, Array(HamiltonDataType.F64_ARRAY, "d")] BoolArray = Annotated[list, Array(HamiltonDataType.BOOL_ARRAY, "?")] EnumArray = Annotated[list, Array(HamiltonDataType.ENUM_ARRAY, "I")] +StrArray = Annotated[list, StringArrayType()] # Compound types: Structure and StructureArray do NOT have simple aliases # because ``Annotated[object, Struct()]`` would erase the concrete type for # mypy. Use inline ``Annotated[ConcreteType, Struct()]`` on each field to # preserve full type safety. The class singletons are exported so call-sites # only need ``Struct()`` and ``StructArray()``. + +# --------------------------------------------------------------------------- +# Type registry and decode_fragment +# --------------------------------------------------------------------------- + +_WIRE_TYPE_REGISTRY: dict[int, WireType] = {} + + +def _register(alias: type) -> None: + meta = alias.__metadata__[0] + _WIRE_TYPE_REGISTRY[meta.type_id] = meta + + +for _alias in [ + I8, + I16, + I32, + I64, + U8, + U16, + U32, + U64, + F32, + F64, + Bool, + Enum, + HcResult, + Str, + I8Array, + I16Array, + I32Array, + I64Array, + U8Array, + U16Array, + U32Array, + U64Array, + F32Array, + F64Array, + BoolArray, + EnumArray, + StrArray, +]: + _register(_alias) + +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE] = Struct() +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE_ARRAY] = StructArray() + + +def decode_fragment(type_id: int, data: bytes) -> Any: + """Decode a DataFragment payload using the unified type registry.""" + wt = _WIRE_TYPE_REGISTRY.get(type_id) + if wt is None: + raise ValueError(f"Unknown DataFragment type_id: {type_id}") + return wt.decode_from(data) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index a5ae2b80e58..7b33649821a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -9,13 +9,16 @@ import asyncio import logging from dataclasses import dataclass -from typing import Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import GetObjectCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + HamiltonIntrospection, + ObjectInfo, +) from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, @@ -33,6 +36,75 @@ logger = logging.getLogger(__name__) + +class ObjectRegistry: + """Maps object paths to addresses. Depth-1 eager by default; lazy resolution beyond.""" + + def __init__(self, backend: "HamiltonTCPBackend"): + self._backend = backend + self._objects: Dict[str, ObjectInfo] = {} + self._root_addresses: List[Address] = [] + + def set_root_addresses(self, addresses: List[Address]) -> None: + self._root_addresses = list(addresses) + + def get_root_addresses(self) -> List[Address]: + return list(self._root_addresses) + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + + def has(self, path: str) -> bool: + return path in self._objects + + def find_path_ending_with(self, suffix: str) -> Optional[str]: + """Return a registered path whose last component equals suffix (e.g. 'Pipette' or 'DoorLock').""" + for path in self._objects: + if path == suffix or path.endswith("." + suffix): + return path + return None + + def address(self, path: str) -> Address: + obj = self._objects.get(path) + if obj is None: + raise KeyError(f"Object '{path}' not discovered") + return obj.address + + async def resolve(self, path: str) -> Address: + if path in self._objects: + return self._objects[path].address + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + parent_path = ".".join(parts[:-1]) + child_name = parts[-1] + introspection = HamiltonIntrospection(self._backend) + + if not parent_path: + if not self._root_addresses: + raise KeyError("No root addresses; run discovery first") + parent_addr = self._root_addresses[0] + parent_info = await introspection.get_object(parent_addr) + parent_info.children = {} + self.register(parent_info.name, parent_info) + if parent_info.name == child_name: + return parent_info.address + raise KeyError(f"Root object is '{parent_info.name}', not '{child_name}'") + + parent_addr = await self.resolve(parent_path) + parent_info = self._objects[parent_path] + for i in range(parent_info.subobject_count): + sub_addr = await introspection.get_subobject_address(parent_info.address, i) + sub_info = await introspection.get_object(sub_addr) + sub_info.children = {} + child_path = f"{parent_path}.{sub_info.name}" + parent_info.children[sub_info.name] = sub_info + self.register(child_path, sub_info) + if sub_info.name == child_name: + return sub_info.address + raise KeyError(f"Child '{child_name}' not found under '{parent_path}'") + + @dataclass class HamiltonError: """Hamilton error response.""" @@ -118,10 +190,7 @@ def __init__( self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} - self._discovered_objects: Dict[str, list[Address]] = {} - - # Instrument-specific addresses (set by subclasses) - self._instrument_addresses: Dict[str, Address] = {} + self._registry = ObjectRegistry(self) async def _ensure_connected(self): """Ensure connection is healthy before operations.""" @@ -315,6 +384,9 @@ async def setup(self): # Step 4: Discover root objects await self._discover_root() + # Step 5: Walk depth-1 (or more) and register interfaces + await self._discover_interfaces(max_depth=1) + logger.info(f"Hamilton backend setup complete. Client ID: {self._client_id}") async def _initialize_connection(self): @@ -449,11 +521,46 @@ async def _discover_root(self): root_objects = self._parse_registration_response(response) logger.info(f"[DISCOVER_ROOT] ✓ Found {len(root_objects)} root objects") - # Store discovered root objects - self._discovered_objects["root"] = root_objects + self._registry.set_root_addresses(root_objects) logger.info(f"✓ Discovery complete: {len(root_objects)} root objects") + async def _discover_interfaces(self, max_depth: int = 1) -> None: + """Walk root and register objects up to max_depth. Default 1 = root + direct children.""" + root_addresses = self._registry.get_root_addresses() + if not root_addresses: + logger.warning("No root addresses; skipping interface discovery") + return + introspection = HamiltonIntrospection(self) + root_addr = root_addresses[0] + await self._register_tree(introspection, root_addr, "", max_depth) + + async def _register_tree( + self, + introspection: HamiltonIntrospection, + addr: Address, + parent_path: str, + max_depth: int, + ) -> None: + """Recursively register one node and its children up to max_depth.""" + info = await introspection.get_object(addr) + info.children = {} + path = info.name if not parent_path else f"{parent_path}.{info.name}" + self._registry.register(path, info) + if max_depth <= 0: + return + for i in range(info.subobject_count): + try: + sub_addr = await introspection.get_subobject_address(addr, i) + sub_info = await introspection.get_object(sub_addr) + sub_info.children = {} + sub_path = f"{path}.{sub_info.name}" + info.children[sub_info.name] = sub_info + self._registry.register(sub_path, sub_info) + await self._register_tree(introspection, sub_addr, path, max_depth - 1) + except Exception as e: + logger.debug("Failed to get subobject %s/%s: %s", path, i, e) + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: """Parse registration response options to extract object addresses. @@ -498,33 +605,6 @@ def _parse_registration_response(self, response: RegistrationResponse) -> list[A return objects - async def _probe_connection(self) -> None: - """Probe the connection by sending ObjectInfo (iface=0, id=1) to the root object. - - If the root has not been discovered yet (e.g. during initial setup), this method - returns immediately — the connection is being established for the first time so - there is nothing to probe against. - - On a retryable connection failure (broken pipe, reset, timeout) the backend is - marked as disconnected and a full reconnect is attempted before returning. After - a successful reconnect the caller can send the real command knowing the connection - is healthy. - - This is called with ensure_connection=False so it never recurses. - """ - root = self._discovered_objects.get("root", []) - if not root: - return - - try: - await self.send_command(GetObjectCommand(root[0]), ensure_connection=False) - except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError) as e: - logger.warning( - f"{self.io._unique_id} Connection probe failed, reconnecting: {e}" - ) - self._connected = False - await self._reconnect() - def _allocate_sequence_number(self, dest_address: Address) -> int: """Allocate next sequence number for destination. @@ -543,75 +623,85 @@ async def send_command( self, command: HamiltonCommand, ensure_connection: bool = True, - ) -> Optional[dict]: + ) -> Optional[Any]: """Send Hamilton command and wait for response. Sets source_address if not already set by caller (for testing). Uses backend's client_address assigned during Protocol 7 initialization. - When ensure_connection=True (default), probes the root object with a - lightweight ObjectInfo call before sending. If the probe detects a dead - connection it reconnects before the real command is sent, so the command - is never sent on a broken connection and is never sent twice. - - Pass ensure_connection=False for commands that are part of the setup / - discovery flow (e.g. HamiltonIntrospection calls), or when a root object - address has not yet been discovered. + When ensure_connection=True (default), on connection error (broken pipe, + reset, timeout, etc.) the backend reconnects and retries the command once. + Pass ensure_connection=False for setup/discovery commands so they are sent + once with no retry. Read/write timeouts are enforced at the backend level (read_timeout and write_timeout passed into HamiltonTCPBackend and used by the Socket). Args: command: Hamilton command to execute. - ensure_connection: If True, probe the root object before sending and - reconnect if the probe fails. If False, send directly without probing. + ensure_connection: If True, reconnect and retry once on connection error. + If False, send once (for setup/discovery). Returns: - Parsed response dictionary, or None if the command has no return data. + Parsed response (Command.Response instance, dict, or None). Raises: ConnectionError: If the connection is not established and auto_reconnect is disabled, or if reconnection fails. RuntimeError: If the Hamilton firmware returns an error action code. """ - if ensure_connection: - await self._probe_connection() - - # Set source address with smart fallback - if command.source_address is None: - if self.client_address is None: - raise RuntimeError("Backend not initialized - call setup() first to assign client_address") - command.source_address = self.client_address - - # Allocate sequence number for this command - command.sequence_number = self._allocate_sequence_number(command.dest_address) - - # Build command message - message = command.build() - - # Log command parameters at debug level (noisy when discovering subobjects) - log_params = command.get_log_params() - logger.debug(f"{command.__class__.__name__} parameters: {log_params}") - - # Send command - await self.write(message) - - # Read response (uses backend read_timeout) - response_message = await self._read_one_message() - assert isinstance(response_message, CommandResponse) + connection_errors = ( + BrokenPipeError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + OSError, + ) + max_attempts = 2 if ensure_connection else 1 + last_error: Optional[BaseException] = None - # Check for error actions - action = Hoi2Action(response_message.hoi.action_code) - if action in ( - Hoi2Action.STATUS_EXCEPTION, - Hoi2Action.COMMAND_EXCEPTION, - Hoi2Action.INVALID_ACTION_RESPONSE, - ): - error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" - logger.error(f"Hamilton error {action}: {error_message}") - raise RuntimeError(f"Hamilton error {action}: {error_message}") + for attempt in range(max_attempts): + try: + if command.source_address is None: + if self.client_address is None: + raise RuntimeError("Backend not initialized - call setup() first to assign client_address") + command.source_address = self.client_address + + command.sequence_number = self._allocate_sequence_number(command.dest_address) + message = command.build() + + log_params = command.get_log_params() + logger.debug(f"{command.__class__.__name__} parameters: {log_params}") + + await self.write(message) + response_message = await self._read_one_message() + assert isinstance(response_message, CommandResponse) + + action = Hoi2Action(response_message.hoi.action_code) + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" + logger.error(f"Hamilton error {action}: {error_message}") + raise RuntimeError(f"Hamilton error {action}: {error_message}") + + return command.interpret_response(response_message) + + except connection_errors as e: + last_error = e + self._connected = False + if not self.auto_reconnect or attempt == max_attempts - 1: + raise + logger.warning( + f"{self.io._unique_id} Command failed (connection error), reconnecting and retrying: {e}" + ) + await self._reconnect() - return command.interpret_response(response_message) + assert last_error is not None + raise last_error async def stop(self): """Stop the backend and close connection.""" @@ -629,5 +719,5 @@ def serialize(self) -> dict: return { **super().serialize(), "client_id": self._client_id, - "instrument_addresses": {k: str(v) for k, v in self._instrument_addresses.items()}, + "registry_paths": list(self._registry._objects.keys()), } From cb2547a5c90f9bd50a6255f70f9d7490f00a0023 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:56:03 -0800 Subject: [PATCH 09/42] Query and store instrument configuration directly from Prep. 1. Get Deck bounds, waste sites, teaching needle location from instrument at setup. 2. Adjusted PrepDeck site y size+spacing based on caliper measurements + tested w/ 4 corner tip pickup at all sites 3. Tip PIckup/Drop commands use instrument's traverse height for z_final. z_seek adjusted to avoid colliisions/make more consistent with other backends. --- .../backends/hamilton/prep_backend.py | 563 +++++++++++++++--- .../resources/hamilton/hamilton_decks.py | 4 +- 2 files changed, 470 insertions(+), 97 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index f0cb8cb5399..48f9ca33426 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -21,20 +21,19 @@ import logging from dataclasses import dataclass from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, List, Optional, Union +from typing import TYPE_CHECKING, Annotated, List, Optional, Tuple, Union from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( - HamiltonIntrospection, -) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( EnumArray, F32, + I8, I16, I16Array, + I64, PaddedBool, PaddedU8, Str, @@ -100,6 +99,59 @@ class TadmRecordingModes(IntEnum): All = 2 +# ============================================================================= +# Hardware config (probed from instrument, immutable) +# ============================================================================= + + +@dataclass(frozen=True) +class DeckBounds: + """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" + + min_x: float + max_x: float + min_y: float + max_y: float + min_z: float + max_z: float + + +@dataclass(frozen=True) +class DeckSiteInfo: + """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + + +@dataclass(frozen=True) +class WasteSiteInfo: + """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" + + index: int + x_position: float + y_position: float + z_position: float + z_seek: float + + +@dataclass(frozen=True) +class InstrumentConfig: + """Instrument hardware configuration probed at setup.""" + + deck_bounds: Optional[DeckBounds] + has_enclosure: bool + safe_speeds_enabled: bool + default_traverse_height: float + deck_sites: Tuple[DeckSiteInfo, ...] + waste_sites: Tuple[WasteSiteInfo, ...] + + # ============================================================================= # Inner parameter dataclasses (wire-type annotated, serialized via from_struct) # ============================================================================= @@ -697,6 +749,32 @@ class TipPositionParameters: z_position: F32 z_seek: F32 + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + ) -> "TipPositionParameters": + """Build from an op location and tip (pickup). + + z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, + comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of + computed default (None = 0). + """ + z = loc.z + tip.total_tip_length + z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + ) + @dataclass class TipDropParameters: @@ -708,6 +786,34 @@ class TipDropParameters: z_seek: F32 drop_type: WEnum + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + drop_type: Optional["TipDropType"] = None, + ) -> "TipDropParameters": + """Build from an op location and tip (drop). + + z_seek default: z_position + total_tip_length + 10mm so tip bottom clears + adjacent tips during lateral approach. z_seek_offset: additive mm on top + of computed default (None = 0). + """ + z = loc.z + tip.total_tip_length + z_seek = z + tip.total_tip_length + 10.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, + ) + @dataclass class TipHeightCalibrationParameters: @@ -1242,12 +1348,9 @@ class PrepGetIsInitialized(PrepCommand): command_id = 2 # GetIsInitialized per introspection_output/MLPrepRoot_MLPrep.txt action_code = 0 # STATUS_REQUEST (query methods use 0, like Nimbus IsInitialized) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetIsInitialized response: single I64 (value), exposed as bool.""" - parser = HoiParamsParser(data) - _, value = parser.parse_next() # I64 - return {"initialized": bool(value)} + @dataclass(frozen=True) + class Response: + value: I64 @dataclass @@ -1384,16 +1487,166 @@ class PrepMethodAbort(PrepCommand): @dataclass class PrepIsParked(PrepCommand): - """Query parked status (cmd=34, dest=MLPrep).""" + """Query parked status (cmd=34, dest=MLPrep). Introspection: IsParked(()) -> parked: I64.""" command_id = 34 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 @dataclass class PrepIsSpread(PrepCommand): - """Query spread status (cmd=35, dest=MLPrep).""" + """Query spread status (cmd=35, dest=MLPrep). Introspection: IsSpread(()) -> parked: I64.""" command_id = 35 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 + + +# ----------------------------------------------------------------------------- +# Wire structs for config responses (used by nested Response and InstrumentConfig) +# ----------------------------------------------------------------------------- + + +@dataclass +class _DeckSiteDefinitionWire: + """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + + +@dataclass +class _WasteSiteDefinitionWire: + """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" + + default_values: PaddedBool + index: WEnum + x_position: I8 + y_position: U16 + z_position: F32 + z_seek: F32 + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _probe_hardware_config +# ----------------------------------------------------------------------------- + + +@dataclass +class _PrepStatusQuery(PrepCommand): + """Base for MLPrep status queries: STATUS_REQUEST (0), no params.""" + + action_code = 0 + + +@dataclass +class PrepGetIsEnclosurePresent(_PrepStatusQuery): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Returns I64 as bool.""" + + command_id = 21 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetSafeSpeedsEnabled(_PrepStatusQuery): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Returns I64 as bool.""" + + command_id = 28 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetDefaultTraverseHeight(_PrepStatusQuery): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(_PrepStatusQuery): + """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). + + Returns the list of tip/needle definitions registered on the instrument. + Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void + (response carries STRUCTURE_ARRAY of tip definition structs). + """ + + command_id = 11 + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(_PrepStatusQuery): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + +@dataclass +class PrepGetDeckSiteDefinitions(_PrepStatusQuery): + """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, + LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 + """ + + command_id = 7 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(_PrepStatusQuery): + """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: + DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, + ZPosition: F32, ZSeek: F32 + """ + + command_id = 12 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] # ============================================================================= @@ -1432,9 +1685,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, ) - self._mlprep_address: Optional[Address] = None - self._pipettor_address: Optional[Address] = None - self._coordinator_address: Optional[Address] = None + self._config: Optional[InstrumentConfig] = None # --------------------------------------------------------------------------- # Setup & discovery @@ -1444,8 +1695,8 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. Order: - 1. TCP + Protocol 7/3 init and root discovery (super().setup()) - 2. Discover Prep objects: MLPrep, Pipettor, ChannelCoordinator + 1. TCP + Protocol 7/3 init, root discovery, and depth-1 interface discovery (super().setup()) + 2. Lazy-resolve Pipettor (depth-2) for commands 3. If force_initialize: always run Initialize(smart=smart). Else: query GetIsInitialized; only run Initialize(smart=smart) when not initialized. 4. Mark setup complete. @@ -1456,9 +1707,9 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): when GetIsInitialized reports not initialized (e.g. reconnect-safe). """ await super().setup() - await self._discover_prep_objects() + await self._registry.resolve("MLPrepRoot.PipettorRoot.Pipettor") - if self._mlprep_address is None: + if not self._registry.has("MLPrepRoot.MLPrep"): raise RuntimeError("MLPrep object not discovered. Cannot proceed with setup.") if force_initialize: @@ -1476,6 +1727,19 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): await self._run_initialize(smart=smart) logger.info("Prep initialization complete") + self._config = await self._probe_hardware_config() + logger.info( + "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " + "deck_bounds=%s, deck_sites=%d, waste_sites=%d", + self._config.has_enclosure, + self._config.safe_speeds_enabled, + self._config.default_traverse_height, + self._config.deck_bounds, + len(self._config.deck_sites), + len(self._config.waste_sites), + ) + + # await self.ensure_spread() self.setup_finished = True async def _run_initialize(self, smart: bool): @@ -1493,45 +1757,74 @@ async def _run_initialize(self, smart: bool): ) ) - async def _discover_prep_objects(self): - """Discover MLPrep, Pipettor, and ChannelCoordinator via introspection.""" - introspection = HamiltonIntrospection(self) - root_objects = self._discovered_objects.get("root", []) - if not root_objects: - logger.warning("No root objects discovered") - return - - root_addr = root_objects[0] + async def _probe_hardware_config(self) -> InstrumentConfig: + """Query MLPrep and DeckConfiguration for hardware config, deck sites, and waste sites.""" + mlprep = self._mlprep_dest() + enc_resp = await self.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await self.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await self.send_command(PrepGetDefaultTraverseHeight(dest=mlprep)) + has_enclosure = bool(enc_resp.value) if enc_resp else False + safe_speeds_enabled = bool(safe_resp.value) if safe_resp else False + default_traverse_height = float(height_resp.value) if height_resp else 0.0 + + deck_bounds: Optional[DeckBounds] = None + deck_sites: Tuple[DeckSiteInfo, ...] = () + waste_sites: Tuple[WasteSiteInfo, ...] = () try: - root_info = await introspection.get_object(root_addr) - for i in range(root_info.subobject_count): - try: - sub_addr = await introspection.get_subobject_address(root_addr, i) - sub_info = await introspection.get_object(sub_addr) - - if sub_info.name == "MLPrep": - self._mlprep_address = sub_addr - logger.info(f"Found MLPrep at {sub_addr}") - - if sub_info.name == "ChannelCoordinator": - self._coordinator_address = sub_addr - logger.info(f"Found ChannelCoordinator at {sub_addr}") - - if sub_info.name == "PipettorRoot": - for j in range(sub_info.subobject_count): - try: - pip_sub_addr = await introspection.get_subobject_address(sub_addr, j) - pip_sub_info = await introspection.get_object(pip_sub_addr) - if pip_sub_info.name == "Pipettor": - self._pipettor_address = pip_sub_addr - logger.info(f"Found Pipettor at {pip_sub_addr}") - break - except Exception as e: - logger.debug(f"Failed to get PipettorRoot subobject {j}: {e}") - except Exception as e: - logger.debug(f"Failed to get root subobject {i}: {e}") - except Exception as e: - logger.warning(f"Failed to discover Prep objects: {e}") + deck_addr = await self._registry.resolve("MLPrepRoot.MLPrepCalibration.DeckConfiguration") + + bounds_resp = await self.send_command(PrepGetDeckBounds(dest=deck_addr)) + if bounds_resp: + deck_bounds = DeckBounds( + min_x=bounds_resp.min_x, + max_x=bounds_resp.max_x, + min_y=bounds_resp.min_y, + max_y=bounds_resp.max_y, + min_z=bounds_resp.min_z, + max_z=bounds_resp.max_z, + ) + + sites_resp = await self.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) + if sites_resp and sites_resp.sites: + deck_sites = tuple( + DeckSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), + ) + for s in sites_resp.sites + ) + logger.info("Discovered %d deck sites", len(deck_sites)) + + waste_resp = await self.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) + if waste_resp and waste_resp.sites: + waste_sites = tuple( + WasteSiteInfo( + index=int(s.index), + x_position=float(s.x_position), + y_position=float(s.y_position), + z_position=float(s.z_position), + z_seek=float(s.z_seek), + ) + for s in waste_resp.sites + ) + logger.info("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + + except (KeyError, RuntimeError) as e: + logger.debug("DeckConfiguration not available: %s", e) + + return InstrumentConfig( + deck_bounds=deck_bounds, + has_enclosure=has_enclosure, + safe_speeds_enabled=safe_speeds_enabled, + default_traverse_height=default_traverse_height, + deck_sites=deck_sites, + waste_sites=waste_sites, + ) # --------------------------------------------------------------------------- # Properties @@ -1539,15 +1832,24 @@ async def _discover_prep_objects(self): @property def mlprep_address(self) -> Optional[Address]: - return self._mlprep_address + try: + return self._registry.address("MLPrepRoot.MLPrep") if self._registry.has("MLPrepRoot.MLPrep") else None + except KeyError: + return None @property def pipettor_address(self) -> Optional[Address]: - return self._pipettor_address + for path in ("MLPrepRoot.PipettorRoot.Pipettor", "MLPrepRoot.ChannelCoordinator"): + if self._registry.has(path): + return self._registry.address(path) + return None @property def coordinator_address(self) -> Optional[Address]: - return self._coordinator_address + try: + return self._registry.address("MLPrepRoot.ChannelCoordinator") if self._registry.has("MLPrepRoot.ChannelCoordinator") else None + except KeyError: + return None @property def num_channels(self) -> int: @@ -1555,15 +1857,24 @@ def num_channels(self) -> int: return 2 def _pipettor_dest(self) -> Address: - dest = self._pipettor_address or self._coordinator_address - if dest is None: - raise RuntimeError("Pipettor/Coordinator not discovered. Call setup() first.") - return dest + for path in ("MLPrepRoot.PipettorRoot.Pipettor", "MLPrepRoot.ChannelCoordinator"): + if self._registry.has(path): + return self._registry.address(path) + raise RuntimeError("Pipettor/Coordinator not discovered. Call setup() first.") def _mlprep_dest(self) -> Address: - if self._mlprep_address is None: - raise RuntimeError("MLPrep address not discovered. Call setup() first.") - return self._mlprep_address + return self._registry.address("MLPrepRoot.MLPrep") + + def _validate_position(self, x: float, y: float, z: float) -> None: + """Raise ValueError if (x, y, z) is outside deck bounds. No-op if config/bounds not set.""" + if self._config is None or self._config.deck_bounds is None: + return + b = self._config.deck_bounds + if not (b.min_x <= x <= b.max_x and b.min_y <= y <= b.max_y and b.min_z <= z <= b.max_z): + raise ValueError( + f"Position ({x}, {y}, {z}) outside deck bounds " + f"(x=[{b.min_x}, {b.max_x}], y=[{b.min_y}, {b.max_y}], z=[{b.min_z}, {b.max_z}])" + ) async def is_initialized(self) -> bool: """Query whether MLPrep reports as initialized (GetIsInitialized, cmd=2). @@ -1575,7 +1886,30 @@ async def is_initialized(self) -> bool: result = await self.send_command(PrepGetIsInitialized(dest=self._mlprep_dest())) if result is None: return False - return result.get("initialized", False) + return bool(result.value) + + async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: + """Return tip/needle definitions registered on the instrument (GetTipAndNeedleDefinitions, cmd=11).""" + result = await self.send_command( + PrepGetTipAndNeedleDefinitions(dest=self._mlprep_dest()) + ) + if result is None or not getattr(result, "definitions", None): + return () + return tuple(result.definitions) + + async def is_parked(self) -> bool: + """Query whether MLPrep is parked (IsParked, cmd=34).""" + result = await self.send_command(PrepIsParked(dest=self._mlprep_dest())) + if result is None: + return False + return bool(result.value) + + async def is_spread(self) -> bool: + """Query whether channels are spread (IsSpread, cmd=35). Pipettor commands typically require spread state.""" + result = await self.send_command(PrepIsSpread(dest=self._mlprep_dest())) + if result is None: + return False + return bool(result.value) # --------------------------------------------------------------------------- # LiquidHandlerBackend abstract methods @@ -1585,15 +1919,34 @@ async def pick_up_tips( self, ops: List[Pickup], use_channels: List[int], - final_z: float = 123.87, + final_z: Optional[float] = None, seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, enable_tadm: bool = False, dispenser_volume: float = 0.0, dispenser_speed: float = 250.0, ): + """Pick up tips. + + The arm moves to z_seek during lateral XY approach, then descends to z_position + to engage the tip. Default z_seek = z_position + fitting_depth + 5mm (tip-type- + aware; avoids descending into the rack during approach). + + Args: + final_z: Traverse/safe height (mm) for the move and Z position after command. + Defaults to the instrument's configured traverse height from setup. + seek_speed: Speed (mm/s) for the seek/approach phase. + z_seek_offset: Additive mm on top of the geometry-based default. None = 0 + (use default only). Use to raise or lower the approach height if needed. + enable_tadm: Enable tip-adjust during pickup. + dispenser_volume: Dispenser volume for TADM (if enabled). + dispenser_speed: Dispenser speed for TADM (if enabled). + """ assert len(ops) == len(use_channels) assert max(use_channels) <= 2, "Only two channels are supported" + resolved_final_z = final_z if final_z is not None else self._config.default_traverse_height + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipPositionParameters] = [] for ch in range(2): @@ -1601,17 +1954,12 @@ async def pick_up_tips( continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "t") - z = loc.z + op.resource.get_tip().total_tip_length - tip_positions.append( - TipPositionParameters( - default_values=False, - channel=_CHANNEL_INDEX[ch], - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z + 12, - ) + params = TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), + z_seek_offset=z_seek_offset, ) + self._validate_position(loc.x, loc.y, params.z_position) + tip_positions.append(params) assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip type" tip = ops[0].tip @@ -1629,7 +1977,7 @@ async def pick_up_tips( PrepPickUpTips( dest=self._pipettor_dest(), tip_positions=tip_positions, - final_z=final_z, + final_z=resolved_final_z, seek_speed=seek_speed, tip_definition=tip_definition, enable_tadm=enable_tadm, @@ -1642,13 +1990,33 @@ async def drop_tips( self, ops: List[Drop], use_channels: List[int], - final_z: float = 123.87, - seek_speed: float = 10.0, + final_z: Optional[float] = None, + seek_speed: float = 30.0, + z_seek_offset: Optional[float] = None, + drop_type: TipDropType = TipDropType.FixedHeight, tip_roll_off_distance: float = 0.0, ): + """Drop tips. + + The arm moves to z_seek during lateral XY approach (tip is on pipette, so tip + bottom is at z_seek - total_tip_length). Default z_seek = z_position + + total_tip_length + 10mm so the tip bottom stays above adjacent tips in the + rack during approach. + + Args: + final_z: Traverse/safe height (mm) for the move and Z position after command. + Defaults to the instrument's configured traverse height from setup. + seek_speed: Speed (mm/s) for the seek/approach phase. + z_seek_offset: Additive mm on top of the geometry-based default. None = 0 + (use default only). Use to raise or lower the approach height if needed. + drop_type: How the tip is released (FixedHeight, Stall, or CLLDSeek). + tip_roll_off_distance: Roll-off distance (mm) for tip release. + """ assert len(ops) == len(use_channels) assert max(use_channels) <= 2, "Only two channels are supported" + resolved_final_z = final_z if final_z is not None else self._config.default_traverse_height + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipDropParameters] = [] for ch in range(2): @@ -1656,24 +2024,19 @@ async def drop_tips( continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "t") - z = loc.z + op.resource.get_tip().total_tip_length - tip_positions.append( - TipDropParameters( - default_values=False, - channel=_CHANNEL_INDEX[ch], - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z + 12, - drop_type=TipDropType.FixedHeight, - ) + params = TipDropParameters.for_op( + _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), + z_seek_offset=z_seek_offset, + drop_type=drop_type, ) + self._validate_position(loc.x, loc.y, params.z_position) + tip_positions.append(params) await self.send_command( PrepDropTips( dest=self._pipettor_dest(), tip_positions=tip_positions, - final_z=final_z, + final_z=resolved_final_z, seek_speed=seek_speed, tip_roll_off_distance=tip_roll_off_distance, ) @@ -1709,6 +2072,7 @@ async def aspirate( continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + self._validate_position(loc.x, loc.y, loc.z) assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" radius = op.resource.get_size_x() / 2 aspirate_parameters.append( @@ -1772,6 +2136,7 @@ async def dispense( continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + self._validate_position(loc.x, loc.y, loc.z) assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" radius = op.resource.get_size_x() / 2 dispense_parameters.append( @@ -1882,6 +2247,10 @@ async def set_deck_light( async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> None: """Move to position (cmd=26).""" + for ax in move_parameters.axis_parameters: + self._validate_position( + move_parameters.gantry_x_position, ax.y_position, ax.z_position + ) await self.send_command( PrepMoveToPosition( dest=self._pipettor_dest(), @@ -1891,6 +2260,10 @@ async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> No async def move_to_position_via_lane(self, move_parameters: GantryMoveXYZParameters) -> None: """Move to position via lane (cmd=27).""" + for ax in move_parameters.axis_parameters: + self._validate_position( + move_parameters.gantry_x_position, ax.y_position, ax.z_position + ) await self.send_command( PrepMoveToPositionViaLane( dest=self._pipettor_dest(), diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 98d5bd3c9f3..4566ba195e6 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -632,11 +632,11 @@ def __init__( for column in range(2): for row in range(4): x = column * 140 - y = row * 100 # ? + y = row * 95.5 # ? spot = ResourceHolder( name=f"spot_{column}_{row}", size_x=127.76, - size_y=96.52, + size_y=92, size_z=0, ) self.assign_child_resource(spot, location=Coordinate(x, y, 0)) From 9ed4169aab2b84dd035ec5fa2870127d33b6911f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:31:14 -0800 Subject: [PATCH 10/42] tcp_backend, introspection overhaul. 1. Detailed error messages if instrument does not accept commands 2. Improved and consolidated interface discovery/addressing 3. Standalone introspection layer, passed as .client to backends. 4. Started 8MPH coammdn implementation --- .../backends/hamilton/nimbus_backend.py | 230 ++---- .../backends/hamilton/nimbus_backend_tests.py | 68 +- .../backends/hamilton/prep_backend.py | 393 +++++++-- .../backends/hamilton/tcp/introspection.py | 772 ++++++++++++------ .../backends/hamilton/tcp/messages.py | 124 ++- .../backends/hamilton/tcp_backend.py | 246 ++++-- 6 files changed, 1289 insertions(+), 544 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 9cd936cf4a1..739ef763041 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1,7 +1,8 @@ """Hamilton Nimbus backend implementation. -This module provides the NimbusBackend class for controlling Hamilton Nimbus -instruments via TCP communication using the Hamilton protocol. +NimbusBackend composes HamiltonTCPClient as self.client for TCP and introspection. +Interfaces: self.client.interfaces..address for routing. Optional presence +via .is_available or firmware probe (DoorLock uses .is_available). """ from __future__ import annotations @@ -26,7 +27,8 @@ U16Array, U32Array, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -420,20 +422,22 @@ class Dispense(NimbusCommand): recording_mode: U16 +# Expected root name from discovery; validated at setup(). +_EXPECTED_ROOT = "NimbusCORE" + + # ============================================================================ # MAIN BACKEND CLASS # ============================================================================ -class NimbusBackend(HamiltonTCPBackend): +class NimbusBackend(LiquidHandlerBackend): """Backend for Hamilton Nimbus liquid handling instruments. - This backend uses TCP communication with the Hamilton protocol to control - Nimbus instruments. It inherits from both TCPBackend (for communication) - and LiquidHandlerBackend (for liquid handling interface). - - Attributes: - _door_lock_available: Whether door lock is available on this instrument. + Uses HamiltonTCPClient (self.client) for TCP communication and introspection; + implements LiquidHandlerBackend for liquid handling. + Interfaces: self.client.interfaces..address for NimbusCORE, Pipette. + Optional (e.g. DoorLock) via .is_available; DoorLock uses _has_door_lock. """ def __init__( @@ -445,17 +449,8 @@ def __init__( auto_reconnect: bool = True, max_reconnect_attempts: int = 3, ): - """Initialize Nimbus backend. - - Args: - host: Hamilton instrument IP address - port: Hamilton instrument port (default: 2000) - read_timeout: Read timeout in seconds - write_timeout: Write timeout in seconds - auto_reconnect: Enable automatic reconnection - max_reconnect_attempts: Maximum reconnection attempts - """ - super().__init__( + super().__init__() + self.client = HamiltonTCPClient( host=host, port=port, read_timeout=read_timeout, @@ -469,65 +464,20 @@ def __init__( self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None self._channel_traversal_height: float = 146.0 # Default traversal height in mm - - # Optional overrides for tests (when set, properties return these instead of registry lookup) - self._address_override_nimbus_core: Optional[Address] = None - self._address_override_pipette: Optional[Address] = None - self._address_override_door_lock: Optional[Address] = None - - @property - def _nimbus_core_address(self) -> Optional[Address]: - if self._address_override_nimbus_core is not None: - return self._address_override_nimbus_core - roots = self._registry.get_root_addresses() - return roots[0] if roots else None - - @_nimbus_core_address.setter - def _nimbus_core_address(self, value: Optional[Address]) -> None: - self._address_override_nimbus_core = value - - @property - def _pipette_address(self) -> Optional[Address]: - if self._address_override_pipette is not None: - return self._address_override_pipette - path = self._registry.find_path_ending_with("Pipette") - if path is None: - return None - try: - return self._registry.address(path) - except KeyError: - return None - - @_pipette_address.setter - def _pipette_address(self, value: Optional[Address]) -> None: - self._address_override_pipette = value - - @property - def _door_lock_address(self) -> Optional[Address]: - if self._address_override_door_lock is not None: - return self._address_override_door_lock - path = self._registry.find_path_ending_with("DoorLock") - if path is None: - return None - try: - return self._registry.address(path) - except KeyError: - return None - - @_door_lock_address.setter - def _door_lock_address(self, value: Optional[Address]) -> None: - self._address_override_door_lock = value + self._has_door_lock: bool = False # Set in setup() from .is_available (no Nimbus probe for enclosure) async def setup(self, unlock_door: bool = False, force_initialize: bool = False): """Set up the Nimbus backend. + Interfaces: self.client.interfaces..address for required paths; optional via .is_available (e.g. _has_door_lock). + This method: 1. Establishes TCP connection and performs protocol initialization 2. Discovers instrument objects 3. Queries channel configuration to get num_channels 4. Queries tip presence 5. Queries initialization status - 6. Locks door if available + 6. Locks door if available (when _has_door_lock) 7. Conditionally initializes NimbusCore with InitializeSmartRoll (only if not initialized) 8. Optionally unlocks door after initialization @@ -535,18 +485,24 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) unlock_door: If True, unlock door after initialization (default: False) force_initialize: If True, force initialization even if already initialized """ - # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration, depth-1 discovery) - await super().setup() + # Call client setup (TCP connection, Protocol 7 init, Protocol 3 registration, depth-1 discovery) + await self.client.setup() + + # Validate discovered root matches this backend + discovered = self.client.discovered_root_name() + if discovered != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{discovered}'. Wrong instrument?" + ) from None - # Ensure required objects are discovered (registry populated by _discover_interfaces(max_depth=1)) - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") - if self._pipette_address is None: - raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") + # Required objects are discovered; .address raises KeyError if missing + nimbus_core = self.client.interfaces.NimbusCORE.address + pipette = self.client.interfaces.NimbusCORE.Pipette.address + self._has_door_lock = self.client.interfaces.NimbusCORE.DoorLock.is_available # Query channel configuration to get num_channels (use discovered address only) try: - config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) + config = await self.client.send_command(GetChannelConfiguration_1(nimbus_core)) assert config is not None, "GetChannelConfiguration_1 command returned None" self._num_channels = config.channels logger.info(f"Channel configuration: {config.channels} channels") @@ -563,7 +519,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Query initialization status (use discovered address only) try: - init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) + init_status = await self.client.send_command(IsInitialized(nimbus_core)) assert init_status is not None, "IsInitialized command returned None" self._is_initialized = bool(init_status.value) logger.info(f"Instrument initialized: {self._is_initialized}") @@ -573,7 +529,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Lock door if available (optional - no error if not found) # This happens before initialization - if self._door_lock_address is not None: + if self._has_door_lock: try: if not await self.is_door_locked(): await self.lock_door() @@ -592,9 +548,9 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] for channel in range(1, self.num_channels + 1): - await self.send_command( + await self.client.send_command( SetChannelConfiguration( - dest=self._pipette_address, + dest=pipette, channel=channel, indexes=[1, 3, 4], enables=[True, False, False, False], @@ -625,9 +581,9 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) roll_distance=None, # Will default to 9.0mm ) - await self.send_command( + await self.client.send_command( InitializeSmartRoll( - dest=self._nimbus_core_address, + dest=nimbus_core, x_positions=x_positions_full, y_positions=y_positions_full, begin_tip_deposit_process=begin_tip_deposit_process_full, @@ -645,7 +601,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) logger.info("Instrument already initialized, skipping initialization") # Unlock door if requested (optional - no error if not found) - if unlock_door and self._door_lock_address is not None: + if unlock_door and self._has_door_lock: try: await self.unlock_door() except RuntimeError: @@ -693,13 +649,10 @@ async def park(self): """Park the instrument. Raises: - RuntimeError: If NimbusCore address was not discovered during setup. + RuntimeError: If NimbusCORE address was not discovered during setup. """ - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore address not discovered. Call setup() first.") - try: - await self.send_command(Park(self._nimbus_core_address)) + await self.client.send_command(Park(self.client.interfaces.NimbusCORE.address)) logger.info("Instrument parked successfully") except Exception as e: logger.error(f"Failed to park instrument: {e}") @@ -709,18 +662,13 @@ async def is_door_locked(self) -> bool: """Check if the door is locked. Returns: - True if door is locked, False if unlocked. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. + True if door is locked, False if unlocked or if door lock is not available. """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + if not self._has_door_lock: + return False try: - status = await self.send_command(IsDoorLocked(self._door_lock_address)) + status = await self.client.send_command(IsDoorLocked(self.client.interfaces.NimbusCORE.DoorLock.address)) assert status is not None, "IsDoorLocked command returned None" return bool(status.locked) except Exception as e: @@ -728,36 +676,24 @@ async def is_door_locked(self) -> bool: raise async def lock_door(self) -> None: - """Lock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + """Lock the door. No-op if door lock is not available.""" + if not self._has_door_lock: + return try: - await self.send_command(LockDoor(self._door_lock_address)) + await self.client.send_command(LockDoor(self.client.interfaces.NimbusCORE.DoorLock.address)) logger.info("Door locked successfully") except Exception as e: logger.error(f"Failed to lock door: {e}") raise async def unlock_door(self) -> None: - """Unlock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + """Unlock the door. No-op if door lock is not available.""" + if not self._has_door_lock: + return try: - await self.send_command(UnlockDoor(self._door_lock_address)) + await self.client.send_command(UnlockDoor(self.client.interfaces.NimbusCORE.DoorLock.address)) logger.info("Door unlocked successfully") except Exception as e: logger.error(f"Failed to unlock door: {e}") @@ -765,7 +701,11 @@ async def unlock_door(self) -> None: async def stop(self): """Stop the backend and close connection.""" - await HamiltonTCPBackend.stop(self) + await self.client.stop() + self.setup_finished = False + + def serialize(self) -> dict: + return {**super().serialize(), **self.client.serialize()} async def request_tip_presence(self) -> List[Optional[bool]]: """Request tip presence on each channel. @@ -774,9 +714,7 @@ async def request_tip_presence(self) -> List[Optional[bool]]: A list of length `num_channels` where each element is `True` if a tip is mounted, `False` if not, or `None` if unknown. """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - tip_status = await self.send_command(IsTipPresent(self._pipette_address)) + tip_status = await self.client.send_command(IsTipPresent(self.client.interfaces.NimbusCORE.Pipette.address)) assert tip_status is not None, "IsTipPresent command returned None" return [bool(v) for v in tip_status.tip_present] @@ -987,9 +925,6 @@ async def pick_up_tips( RuntimeError: If pipette address or deck is not set ValueError: If deck is not a NimbusDeck and minimum_traverse_height_at_beginning_of_a_command is not provided """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1032,7 +967,7 @@ async def pick_up_tips( # Create and send command command = PickupTips( - dest=self._pipette_address, + dest=self.client.interfaces.NimbusCORE.Pipette.address, channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1043,7 +978,7 @@ async def pick_up_tips( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Picked up tips on channels {use_channels}") except Exception as e: logger.error(f"Failed to pick up tips: {e}") @@ -1084,9 +1019,6 @@ async def drop_tips( RuntimeError: If pipette address or deck is not set ValueError: If operations mix waste and regular resources """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1132,7 +1064,7 @@ async def drop_tips( ) command = DropTipsRoll( - dest=self._pipette_address, + dest=self.client.interfaces.NimbusCORE.Pipette.address, channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1165,7 +1097,7 @@ async def drop_tips( ) command = DropTips( - dest=self._pipette_address, + dest=self.client.interfaces.NimbusCORE.Pipette.address, channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1177,7 +1109,7 @@ async def drop_tips( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Dropped tips on channels {use_channels}") except Exception as e: logger.error(f"Failed to drop tips: {e}") @@ -1232,9 +1164,6 @@ async def aspirate( Raises: RuntimeError: If pipette address or deck is not set """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1250,10 +1179,10 @@ async def aspirate( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) + await self.client.send_command(EnableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) logger.info("Enabled ADC before aspirate") else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) + await self.client.send_command(DisableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) logger.info("Disabled ADC before aspirate") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -1262,9 +1191,9 @@ async def aspirate( for channel_idx in use_channels: channel_num = channel_idx + 1 # Convert to 1-based try: - config = await self.send_command( + config = await self.client.send_command( GetChannelConfiguration( - self._pipette_address, + self.client.interfaces.NimbusCORE.Pipette.address, channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) @@ -1439,7 +1368,7 @@ async def aspirate( # Create and send Aspirate command command = Aspirate( - dest=self._pipette_address, + dest=self.client.interfaces.NimbusCORE.Pipette.address, aspirate_type=aspirate_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -1476,7 +1405,7 @@ async def aspirate( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Aspirated on channels {use_channels}") except Exception as e: logger.error(f"Failed to aspirate: {e}") @@ -1531,9 +1460,6 @@ async def dispense( Raises: RuntimeError: If pipette address or deck is not set """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1549,10 +1475,10 @@ async def dispense( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) + await self.client.send_command(EnableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) logger.info("Enabled ADC before dispense") else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) + await self.client.send_command(DisableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) logger.info("Disabled ADC before dispense") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -1561,9 +1487,9 @@ async def dispense( for channel_idx in use_channels: channel_num = channel_idx + 1 # Convert to 1-based try: - config = await self.send_command( + config = await self.client.send_command( GetChannelConfiguration( - self._pipette_address, + self.client.interfaces.NimbusCORE.Pipette.address, channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) @@ -1741,7 +1667,7 @@ async def dispense( # Create and send Dispense command command = Dispense( - dest=self._pipette_address, + dest=self.client.interfaces.NimbusCORE.Pipette.address, dispense_type=dispense_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -1778,7 +1704,7 @@ async def dispense( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Dispensed on channels {use_channels}") except Exception as e: logger.error(f"Failed to dispense: {e}") diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index abfea28b4b5..c55ae3e7960 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -41,6 +41,7 @@ I16Array, U16, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ObjectInfo from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol, Hoi2Action from pylabrobot.liquid_handling.standard import ( @@ -555,17 +556,15 @@ class TestNimbusBackendUnit(unittest.IsolatedAsyncioTestCase): async def test_backend_init(self): backend = NimbusBackend(host="192.168.1.100", port=2000) - self.assertEqual(backend.io._host, "192.168.1.100") - self.assertEqual(backend.io._port, 2000) + self.assertEqual(backend.client.io._host, "192.168.1.100") + self.assertEqual(backend.client.io._port, 2000) self.assertIsNone(backend._num_channels) - self.assertIsNone(backend._pipette_address) - self.assertIsNone(backend._door_lock_address) - self.assertIsNone(backend._nimbus_core_address) + self.assertEqual(backend.client._registry._objects, {}) self.assertEqual(backend._channel_traversal_height, 146.0) async def test_backend_init_default_port(self): backend = NimbusBackend(host="192.168.1.100") - self.assertEqual(backend.io._port, 2000) + self.assertEqual(backend.client.io._port, 2000) async def test_num_channels_before_setup_raises(self): backend = NimbusBackend(host="192.168.1.100") @@ -643,12 +642,27 @@ def _mock_send_command_response(command): def _setup_backend() -> NimbusBackend: - """Create a NimbusBackend with pre-configured state for testing.""" + """Create a NimbusBackend with pre-configured state for testing (registry populated). + + Tests seed the registry with Address(...) and ObjectInfo directly (rather than + going through the proxy) so that wire-format and address values are under test. + """ backend = NimbusBackend(host="192.168.1.100", port=2000) backend._num_channels = 8 - backend._pipette_address = Address(1, 1, 257) - backend._door_lock_address = Address(1, 1, 268) - backend._nimbus_core_address = Address(1, 1, 48896) + backend.client._registry.set_root_addresses([Address(1, 1, 48896)]) + backend.client._registry.register( + "NimbusCORE", + ObjectInfo("NimbusCORE", "1.0", 10, 2, Address(1, 1, 48896)), + ) + backend.client._registry.register( + "NimbusCORE.Pipette", + ObjectInfo("Pipette", "1.0", 20, 0, Address(1, 1, 257)), + ) + backend.client._registry.register( + "NimbusCORE.DoorLock", + ObjectInfo("DoorLock", "1.0", 3, 0, Address(1, 1, 268)), + ) + backend._has_door_lock = True # DoorLock is present in registry backend._is_initialized = True return backend @@ -666,7 +680,7 @@ class TestNimbusBackendCommands(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = _setup_backend() self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] def _get_command(self, cmd_type): for call in self.mock_send.call_args_list: @@ -696,22 +710,26 @@ async def test_park(self): self.assertIsInstance(self._get_command(Park), Park) async def test_door_methods_without_address_raise(self): - self.backend._door_lock_address = None - - with self.assertRaises(RuntimeError): - await self.backend.lock_door() + # When door lock is not available (_has_door_lock=False), methods return early without sending. + self.backend._has_door_lock = False + self.mock_send.reset_mock() - with self.assertRaises(RuntimeError): - await self.backend.unlock_door() + await self.backend.lock_door() + await self.backend.unlock_door() + self.assertEqual(self.mock_send.call_count, 0) - with self.assertRaises(RuntimeError): - await self.backend.is_door_locked() + result = await self.backend.is_door_locked() + self.assertFalse(result) + self.assertEqual(self.mock_send.call_count, 0) async def test_park_without_address_raises(self): - self.backend._nimbus_core_address = None + # Backend with no registry entries (e.g. setup() not run); .address raises KeyError + backend = NimbusBackend(host="192.168.1.100", port=2000) + backend._num_channels = 8 + backend._is_initialized = True - with self.assertRaises(RuntimeError): - await self.backend.park() + with self.assertRaises(KeyError): + await backend.park() class TestNimbusBackendSerialization(unittest.IsolatedAsyncioTestCase): @@ -719,7 +737,7 @@ class TestNimbusBackendSerialization(unittest.IsolatedAsyncioTestCase): async def test_serialize(self): backend = NimbusBackend(host="192.168.1.100", port=2000) - backend._client_id = 5 + backend.client._client_id = 5 serialized = backend.serialize() self.assertEqual(serialized["client_id"], 5) @@ -734,7 +752,7 @@ async def asyncSetUp(self): self.deck = NimbusDeck() self.backend = _setup_backend_with_deck(self.deck) self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] self.tip_rack = hamilton_96_tiprack_300uL("tip_rack") self.deck.assign_child_resource(self.tip_rack, rails=1) @@ -1110,7 +1128,7 @@ async def asyncSetUp(self): self.deck = NimbusDeck() self.backend = _setup_backend_with_deck(self.deck) self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] def _get_commands(self, cmd_type): return [ diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 48f9ca33426..bd6d1e25fb0 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1,19 +1,20 @@ """Hamilton Prep backend implementation. -Uses HamiltonTCPBackend (Protocol 7/3, introspection) and shares the same -TCP codec as NimbusBackend. Discovers MLPrep, Pipettor, and ChannelCoordinator -via introspection and sends commands as HamiltonCommand subclasses. - Three-layer design: -- **Inner structs** (e.g. ``CommonParameters``, ``NoLldParameters``): reusable - wire protocol building blocks, no defaults. ``.default()`` only where it - means "tell firmware to use its own defaults". -- **Command classes** (e.g. ``PrepDropTips``): pure wire shape + identity. - ``@dataclass`` with ``dest: Address`` + ``Annotated`` payload fields, no - defaults. ``build_parameters()`` uses ``HoiParams.from_struct(self)``. -- **PrepBackend methods**: single source of truth for all Prep-specific - defaults. Flat, named, typed kwargs with defaults. +- **HamiltonTCPClient** (``self.client``): Transport and introspection. + All device communication goes through ``self.client.send_command()``. + Address resolution: ``self.client.interfaces..address``. + +- **Command dataclasses** (e.g. ``PrepDropTips``, ``MphPickupTips``): Pure wire shapes. + ``@dataclass`` with ``dest: Address`` + ``Annotated`` payload fields; no defaults; + ``build_parameters()`` uses ``HoiParams.from_struct(self)``. + +- **PrepBackend methods**: Domain logic and defaults. + Single source of truth for Prep-specific parameter defaults. + +Standalone access: ``lh.backend.client.interfaces.MLPrepRoot.MphRoot.MPH.address``, +``HamiltonIntrospection(lh.backend.client)``. """ from __future__ import annotations @@ -44,7 +45,8 @@ U8Array, Enum as WEnum, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -61,6 +63,7 @@ SingleChannelDispense, ) from pylabrobot.resources import Tip +from pylabrobot.resources.tip_rack import TipSpot if TYPE_CHECKING: pass @@ -147,9 +150,9 @@ class InstrumentConfig: deck_bounds: Optional[DeckBounds] has_enclosure: bool safe_speeds_enabled: bool - default_traverse_height: float deck_sites: Tuple[DeckSiteInfo, ...] waste_sites: Tuple[WasteSiteInfo, ...] + default_traverse_height: Optional[float] = None # None if probe failed; user can set via set_default_traverse_height # ============================================================================= @@ -1092,6 +1095,50 @@ class PrepDropTips(PrepCommand): tip_roll_off_distance: F32 +@dataclass +class MphPickupTips(PrepCommand): + """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). + + Resolved introspection signature: + PickupTips(tipParameters: struct(iface=1), finalZ: f32, + tipDefinition: struct(iface=1), tadm: bool, + dispenserVolume: f32, dispenserSpeed: f32, + tipMask: u32) -> { seekSpeed: List[u16] } + + The MPH takes a SINGLE struct (type_57) for tip_parameters, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + tip_parameters: Annotated[TipPositionParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + tip_mask: U32 + + +@dataclass +class MphDropTips(PrepCommand): + """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). + + Resolved introspection signature: + DropTips(dropTipParameters: struct(iface=1), finalZ: f32, + tipRollOffDistance: f32) -> seekSpeed: List[u16] + + Single struct (type_57) for drop position — all probes drop together. + """ + + command_id = 12 + drop_parameters: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + @dataclass class PrepPickUpToolById(PrepCommand): """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" @@ -1659,13 +1706,16 @@ class Response: } -class PrepBackend(HamiltonTCPBackend): +# Expected root name from discovery; validated at setup(). +_EXPECTED_ROOT = "MLPrepRoot" + + +class PrepBackend(LiquidHandlerBackend): """Backend for Hamilton Prep instruments using the shared TCP stack. - Discovers MLPrep, Pipettor, and ChannelCoordinator via Protocol-7/3 - introspection, then sends typed PrepCommand instances. All parameter - serialisation is handled by HoiParams.from_struct() -- no manual builder - calls. + Uses HamiltonTCPClient (self.client) for communication and introspection; + implements LiquidHandlerBackend for liquid handling. + Interfaces: self.client.interfaces..address for MLPrep, Pipettor, MPH. """ def __init__( @@ -1676,8 +1726,10 @@ def __init__( write_timeout: float = 30.0, auto_reconnect: bool = True, max_reconnect_attempts: int = 3, + default_traverse_height: Optional[float] = None, ): - super().__init__( + super().__init__() + self.client = HamiltonTCPClient( host=host, port=port, read_timeout=read_timeout, @@ -1686,6 +1738,15 @@ def __init__( max_reconnect_attempts=max_reconnect_attempts, ) self._config: Optional[InstrumentConfig] = None + self._user_traverse_height: Optional[float] = default_traverse_height + + def set_default_traverse_height(self, value: float) -> None: + """Set the default traverse height (mm) used when final_z is not passed to pick_up_tips/drop_tips. + + Use this when the instrument did not report a traverse height at setup, or to override + the probed value. + """ + self._user_traverse_height = value # --------------------------------------------------------------------------- # Setup & discovery @@ -1694,8 +1755,10 @@ def __init__( async def setup(self, smart: bool = True, force_initialize: bool = False): """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. + Interfaces: .address for MLPrep/Pipettor; depth-2 paths resolved in setup. + Order: - 1. TCP + Protocol 7/3 init, root discovery, and depth-1 interface discovery (super().setup()) + 1. TCP + Protocol 7/3 init, root discovery, and depth-1 interface discovery (self.client.setup()) 2. Lazy-resolve Pipettor (depth-2) for commands 3. If force_initialize: always run Initialize(smart=smart). Else: query GetIsInitialized; only run Initialize(smart=smart) when not initialized. @@ -1706,11 +1769,27 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): force_initialize: If True, always run Initialize. If False, run Initialize only when GetIsInitialized reports not initialized (e.g. reconnect-safe). """ - await super().setup() - await self._registry.resolve("MLPrepRoot.PipettorRoot.Pipettor") + await self.client.setup() - if not self._registry.has("MLPrepRoot.MLPrep"): - raise RuntimeError("MLPrep object not discovered. Cannot proceed with setup.") + # Validate discovered root matches this backend + discovered = self.client.discovered_root_name() + if discovered != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{discovered}'. Wrong instrument?" + ) from None + + await self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.resolve() + + try: + await self.client.interfaces.MLPrepRoot.MphRoot.MPH.resolve() + logger.info("MPH head discovered at %s", self.client.interfaces.MLPrepRoot.MphRoot.MPH.address) + except Exception as e: + logger.info("MPH head not available (instrument may not have MPH): %s", e) + + try: + self.client.interfaces.MLPrepRoot.MLPrep.address + except KeyError: + raise RuntimeError("MLPrep object not discovered. Cannot proceed with setup.") from None if force_initialize: await self._run_initialize(smart=smart) @@ -1744,9 +1823,9 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): async def _run_initialize(self, smart: bool): """Send PrepInitialize to MLPrep (shared by setup).""" - await self.send_command( + await self.client.send_command( PrepInitialize( - dest=self._mlprep_dest(), + dest=self.client.interfaces.MLPrepRoot.MLPrep.address, smart=smart, tip_drop_params=InitTipDropParameters( default_values=True, @@ -1759,21 +1838,22 @@ async def _run_initialize(self, smart: bool): async def _probe_hardware_config(self) -> InstrumentConfig: """Query MLPrep and DeckConfiguration for hardware config, deck sites, and waste sites.""" - mlprep = self._mlprep_dest() - enc_resp = await self.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) - safe_resp = await self.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) - height_resp = await self.send_command(PrepGetDefaultTraverseHeight(dest=mlprep)) + mlprep = self.client.interfaces.MLPrepRoot.MLPrep.address + enc_resp = await self.client.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await self.client.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await self.client.send_command(PrepGetDefaultTraverseHeight(dest=mlprep)) has_enclosure = bool(enc_resp.value) if enc_resp else False safe_speeds_enabled = bool(safe_resp.value) if safe_resp else False - default_traverse_height = float(height_resp.value) if height_resp else 0.0 + default_traverse_height = float(height_resp.value) if height_resp else None deck_bounds: Optional[DeckBounds] = None deck_sites: Tuple[DeckSiteInfo, ...] = () waste_sites: Tuple[WasteSiteInfo, ...] = () try: - deck_addr = await self._registry.resolve("MLPrepRoot.MLPrepCalibration.DeckConfiguration") + await self.client.interfaces.MLPrepRoot.MLPrepCalibration.DeckConfiguration.resolve() + deck_addr = self.client.interfaces.MLPrepRoot.MLPrepCalibration.DeckConfiguration.address - bounds_resp = await self.send_command(PrepGetDeckBounds(dest=deck_addr)) + bounds_resp = await self.client.send_command(PrepGetDeckBounds(dest=deck_addr)) if bounds_resp: deck_bounds = DeckBounds( min_x=bounds_resp.min_x, @@ -1784,7 +1864,7 @@ async def _probe_hardware_config(self) -> InstrumentConfig: max_z=bounds_resp.max_z, ) - sites_resp = await self.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) + sites_resp = await self.client.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) if sites_resp and sites_resp.sites: deck_sites = tuple( DeckSiteInfo( @@ -1800,7 +1880,7 @@ async def _probe_hardware_config(self) -> InstrumentConfig: ) logger.info("Discovered %d deck sites", len(deck_sites)) - waste_resp = await self.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) + waste_resp = await self.client.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) if waste_resp and waste_resp.sites: waste_sites = tuple( WasteSiteInfo( @@ -1821,9 +1901,9 @@ async def _probe_hardware_config(self) -> InstrumentConfig: deck_bounds=deck_bounds, has_enclosure=has_enclosure, safe_speeds_enabled=safe_speeds_enabled, - default_traverse_height=default_traverse_height, deck_sites=deck_sites, waste_sites=waste_sites, + default_traverse_height=default_traverse_height, ) # --------------------------------------------------------------------------- @@ -1833,21 +1913,21 @@ async def _probe_hardware_config(self) -> InstrumentConfig: @property def mlprep_address(self) -> Optional[Address]: try: - return self._registry.address("MLPrepRoot.MLPrep") if self._registry.has("MLPrepRoot.MLPrep") else None + return self.client.interfaces.MLPrepRoot.MLPrep.address except KeyError: return None @property def pipettor_address(self) -> Optional[Address]: - for path in ("MLPrepRoot.PipettorRoot.Pipettor", "MLPrepRoot.ChannelCoordinator"): - if self._registry.has(path): - return self._registry.address(path) - return None + try: + return self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address + except KeyError: + return None @property def coordinator_address(self) -> Optional[Address]: try: - return self._registry.address("MLPrepRoot.ChannelCoordinator") if self._registry.has("MLPrepRoot.ChannelCoordinator") else None + return self.client.interfaces.MLPrepRoot.ChannelCoordinator.address except KeyError: return None @@ -1856,15 +1936,6 @@ def num_channels(self) -> int: """Prep has 2 channels (front and rear).""" return 2 - def _pipettor_dest(self) -> Address: - for path in ("MLPrepRoot.PipettorRoot.Pipettor", "MLPrepRoot.ChannelCoordinator"): - if self._registry.has(path): - return self._registry.address(path) - raise RuntimeError("Pipettor/Coordinator not discovered. Call setup() first.") - - def _mlprep_dest(self) -> Address: - return self._registry.address("MLPrepRoot.MLPrep") - def _validate_position(self, x: float, y: float, z: float) -> None: """Raise ValueError if (x, y, z) is outside deck bounds. No-op if config/bounds not set.""" if self._config is None or self._config.deck_bounds is None: @@ -1876,22 +1947,37 @@ def _validate_position(self, x: float, y: float, z: float) -> None: f"(x=[{b.min_x}, {b.max_x}], y=[{b.min_y}, {b.max_y}], z=[{b.min_z}, {b.max_z}])" ) + def _resolve_traverse_height(self, final_z: Optional[float]) -> float: + """Resolve final_z: explicit arg > user-set default > probed value. Raises if none available.""" + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + if self._config is not None and self._config.default_traverse_height is not None: + return self._config.default_traverse_height + raise RuntimeError( + "Default traverse height is required for this operation but could not be determined. " + "Either pass final_z explicitly to this call, or set it via " + "PrepBackend(..., default_traverse_height=) or backend.set_default_traverse_height(). " + "If the instrument supports it, the value is also probed during setup(); ensure setup() completed successfully." + ) from None + async def is_initialized(self) -> bool: """Query whether MLPrep reports as initialized (GetIsInitialized, cmd=2). Uses MLPrep method from introspection: GetIsInitialized(()) -> value: I64. - Requires MLPrep to be discovered (e.g. after super().setup() and + Requires MLPrep to be discovered (e.g. after self.client.setup() and _discover_prep_objects()). Call before or after PrepInitialize to test. """ - result = await self.send_command(PrepGetIsInitialized(dest=self._mlprep_dest())) + result = await self.client.send_command(PrepGetIsInitialized(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) if result is None: return False return bool(result.value) async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: """Return tip/needle definitions registered on the instrument (GetTipAndNeedleDefinitions, cmd=11).""" - result = await self.send_command( - PrepGetTipAndNeedleDefinitions(dest=self._mlprep_dest()) + result = await self.client.send_command( + PrepGetTipAndNeedleDefinitions(dest=self.client.interfaces.MLPrepRoot.MLPrep.address) ) if result is None or not getattr(result, "definitions", None): return () @@ -1899,14 +1985,14 @@ async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: async def is_parked(self) -> bool: """Query whether MLPrep is parked (IsParked, cmd=34).""" - result = await self.send_command(PrepIsParked(dest=self._mlprep_dest())) + result = await self.client.send_command(PrepIsParked(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) if result is None: return False return bool(result.value) async def is_spread(self) -> bool: """Query whether channels are spread (IsSpread, cmd=35). Pipettor commands typically require spread state.""" - result = await self.send_command(PrepIsSpread(dest=self._mlprep_dest())) + result = await self.client.send_command(PrepIsSpread(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) if result is None: return False return bool(result.value) @@ -1934,7 +2020,8 @@ async def pick_up_tips( Args: final_z: Traverse/safe height (mm) for the move and Z position after command. - Defaults to the instrument's configured traverse height from setup. + If None, uses the user-set value (constructor or set_default_traverse_height) or the + value probed from the instrument at setup. Raises RuntimeError if none is available. seek_speed: Speed (mm/s) for the seek/approach phase. z_seek_offset: Additive mm on top of the geometry-based default. None = 0 (use default only). Use to raise or lower the approach height if needed. @@ -1945,7 +2032,7 @@ async def pick_up_tips( assert len(ops) == len(use_channels) assert max(use_channels) <= 2, "Only two channels are supported" - resolved_final_z = final_z if final_z is not None else self._config.default_traverse_height + resolved_final_z = self._resolve_traverse_height(final_z) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipPositionParameters] = [] @@ -1973,9 +2060,9 @@ async def pick_up_tips( is_tool=False, ) - await self.send_command( + await self.client.send_command( PrepPickUpTips( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, tip_positions=tip_positions, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2005,7 +2092,8 @@ async def drop_tips( Args: final_z: Traverse/safe height (mm) for the move and Z position after command. - Defaults to the instrument's configured traverse height from setup. + If None, uses the user-set value (constructor or set_default_traverse_height) or the + value probed from the instrument at setup. Raises RuntimeError if none is available. seek_speed: Speed (mm/s) for the seek/approach phase. z_seek_offset: Additive mm on top of the geometry-based default. None = 0 (use default only). Use to raise or lower the approach height if needed. @@ -2015,7 +2103,7 @@ async def drop_tips( assert len(ops) == len(use_channels) assert max(use_channels) <= 2, "Only two channels are supported" - resolved_final_z = final_z if final_z is not None else self._config.default_traverse_height + resolved_final_z = self._resolve_traverse_height(final_z) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipDropParameters] = [] @@ -2032,9 +2120,9 @@ async def drop_tips( self._validate_position(loc.x, loc.y, params.z_position) tip_positions.append(params) - await self.send_command( + await self.client.send_command( PrepDropTips( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, tip_positions=tip_positions, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2042,6 +2130,140 @@ async def drop_tips( ) ) + # --------------------------------------------------------------------------- + # MPH head tip operations + # --------------------------------------------------------------------------- + + async def pick_up_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + tip_mask: int = 0xFF, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + ) -> None: + """Pick up tips with the MPH (multi-probe) head. + + Routes to MLPrepRoot.MphRoot.MPH (PickupTips, iface=1 id=9). The MPH + takes a single reference position (type_57 = single struct) rather than + a per-channel list (type_61). All 8 probes move as one unit; tip_mask + selects which channels engage (default 0xFF = all 8). + + The first TipSpot is used as the reference position. For a full column + pickup, pass tip_rack["A1:H1"] — only the first spot's (x,y,z) is sent, + all 8 probes engage via tip_mask. + + Args: + tip_spot: A single TipSpot or a list. The first spot is used as the + reference position for all probes. + tip_mask: 8-bit bitmask of active MPH channels (bit 0 = channel 0, + bit 7 = channel 7). Default 0xFF picks up with all 8 channels. + final_z: Traverse/safe height (mm) after command. If None, uses the + probed or user-set default traverse height. + seek_speed: Speed (mm/s) for the Z approach phase. + z_seek_offset: Additive mm offset on top of the geometry-based seek Z + (tip.fitting_depth + 5 mm). None = 0. + enable_tadm: Enable tip-attachment detection (TADM) during pickup. + dispenser_volume: Dispenser volume for TADM (ignored when False). + dispenser_speed: Dispenser speed for TADM (ignored when False). + """ + if isinstance(tip_spot, list): + spots = tip_spot + else: + spots = [tip_spot] + if not spots: + raise ValueError("pick_up_tips_mph: tip_spot list is empty") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = spots[0] + tip = ref_spot.get_tip() + loc = ref_spot.get_absolute_location("c", "c", "t") + tip_parameters = TipPositionParameters.for_op( + ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset + ) + self._validate_position(loc.x, loc.y, tip_parameters.z_position) + + tip_definition = TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + + await self.client.send_command( + MphPickupTips( + dest=self.client.interfaces.MLPrepRoot.MphRoot.MPH.address, + tip_parameters=tip_parameters, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + tip_mask=tip_mask, + ) + ) + + async def drop_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + final_z: Optional[float] = None, + seek_speed: float = 30.0, + z_seek_offset: Optional[float] = None, + drop_type: TipDropType = TipDropType.FixedHeight, + tip_roll_off_distance: float = 0.0, + ) -> None: + """Drop tips held by the MPH head. + + Routes to MLPrepRoot.MphRoot.MPH (DropTips, iface=1 id=12). The MPH + takes a single reference position (type_57 = single struct); all probes + drop together at the same location. + + Args: + tip_spot: Target drop position. The first spot is used as the reference + position for all probes. + final_z: Traverse/safe height (mm) after command. If None, uses the + probed or user-set default traverse height. + seek_speed: Speed (mm/s) for the Z seek/approach phase. + z_seek_offset: Additive mm offset on top of the geometry-based seek Z. + None = 0 (default seeks tip_bottom + total_tip_length + 10 mm). + drop_type: How tips are released (FixedHeight, Stall, or CLLDSeek). + tip_roll_off_distance: Roll-off distance (mm) for tip release. + """ + if isinstance(tip_spot, list): + spots = tip_spot + else: + spots = [tip_spot] + if not spots: + raise ValueError("drop_tips_mph: tip_spot list is empty") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = spots[0] + tip = ref_spot.get_tip() + loc = ref_spot.get_absolute_location("c", "c", "t") + drop_parameters = TipDropParameters.for_op( + ChannelIndex.MPHChannel, loc, tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) + self._validate_position(loc.x, loc.y, drop_parameters.z_position) + + await self.client.send_command( + MphDropTips( + dest=self.client.interfaces.MLPrepRoot.MphRoot.MPH.address, + drop_parameters=drop_parameters, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=tip_roll_off_distance, + ) + ) + async def aspirate( self, ops: List[SingleChannelAspiration], @@ -2099,9 +2321,9 @@ async def aspirate( ) ) - await self.send_command( + await self.client.send_command( PrepAspirateNoLldMonitoring( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, aspirate_parameters=aspirate_parameters, ) ) @@ -2163,9 +2385,9 @@ async def dispense( ) ) - await self.send_command( + await self.client.send_command( PrepDispenseNoLld( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, dispense_parameters=dispense_parameters, ) ) @@ -2204,36 +2426,36 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: async def park(self) -> None: """Park the instrument.""" - await self.send_command(PrepPark(dest=self._mlprep_dest())) + await self.client.send_command(PrepPark(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) async def spread(self) -> None: """Spread channels.""" - await self.send_command(PrepSpread(dest=self._mlprep_dest())) + await self.client.send_command(PrepSpread(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) async def method_begin(self, automatic_pause: bool = False) -> None: """Signal the start of a liquid-handling method.""" - await self.send_command( + await self.client.send_command( PrepMethodBegin( - dest=self._mlprep_dest(), + dest=self.client.interfaces.MLPrepRoot.MLPrep.address, automatic_pause=automatic_pause, ) ) async def method_end(self) -> None: """Signal the end of a liquid-handling method.""" - await self.send_command(PrepMethodEnd(dest=self._mlprep_dest())) + await self.client.send_command(PrepMethodEnd(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) async def method_abort(self) -> None: """Abort the current method.""" - await self.send_command(PrepMethodAbort(dest=self._mlprep_dest())) + await self.client.send_command(PrepMethodAbort(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) async def set_deck_light( self, white: int, red: int, green: int, blue: int ) -> None: """Set the deck LED colour.""" - await self.send_command( + await self.client.send_command( PrepSetDeckLight( - dest=self._mlprep_dest(), + dest=self.client.interfaces.MLPrepRoot.MLPrep.address, white=white, red=red, green=green, @@ -2251,9 +2473,9 @@ async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> No self._validate_position( move_parameters.gantry_x_position, ax.y_position, ax.z_position ) - await self.send_command( + await self.client.send_command( PrepMoveToPosition( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, move_parameters=move_parameters, ) ) @@ -2264,9 +2486,16 @@ async def move_to_position_via_lane(self, move_parameters: GantryMoveXYZParamete self._validate_position( move_parameters.gantry_x_position, ax.y_position, ax.z_position ) - await self.send_command( + await self.client.send_command( PrepMoveToPositionViaLane( - dest=self._pipettor_dest(), + dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, move_parameters=move_parameters, ) ) + + async def stop(self) -> None: + await self.client.stop() + self.setup_finished = False + + def serialize(self) -> dict: + return {**super().serialize(), **self.client.serialize()} diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 0a66bc7447a..7a54403ca4f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -1,28 +1,51 @@ """Hamilton TCP Introspection API. -This module provides dynamic discovery of Hamilton instrument capabilities -using Interface 0 introspection methods. It allows discovering available -objects, methods, interfaces, enums, and structs at runtime. +Wraps HamiltonTCPClient to provide dynamic discovery of instrument capabilities +via Interface 0 methods (GetObject, GetMethod, GetStructs, GetEnums, +GetInterfaces, GetSubobjectAddress). + +Canonical usage:: + + intro = HamiltonIntrospection(client) # standalone + intro = HamiltonIntrospection(lh.backend.client) # from LiquidHandler + + # Build a cached registry for one object (uses InterfaceDescriptors): + registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") + registry.print_summary() + + # Diagnose a COMMAND_EXCEPTION: + print(await intro.diagnose_error(str(e), registry)) + + # Resolve a method signature: + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, registry) """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Annotated, Any, Dict, List +from typing import Annotated, Dict, List, Optional, Set, Union from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, + inspect_hoi_params, +) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - CountedFlatArray, HamiltonDataType, + I8Array, I32, + I32Array, Str, + StrArray, U8, + U8Array, U16, U32, + U32Array, ) logger = logging.getLogger(__name__) @@ -47,18 +70,6 @@ def resolve_type_id(type_id: int) -> str: return f"UNKNOWN_TYPE_{type_id}" -def resolve_type_ids(type_ids: List[int]) -> List[str]: - """Resolve list of Hamilton type IDs to readable names. - - Args: - type_ids: List of Hamilton data type IDs - - Returns: - List of human-readable type names - """ - return [resolve_type_id(tid) for tid in type_ids] - - # ============================================================================ # INTROSPECTION TYPE MAPPING # ============================================================================ @@ -85,8 +96,10 @@ def resolve_type_ids(type_ids: List[int]) -> List[str]: 45: "List[u16]", 49: "List[i32]", 53: "List[u32]", - 61: "List[struct]", # Complex type, needs source_id + struct_id + 57: "struct", # Complex type: (57, source_id, ref_id) → single struct + 61: "List[struct]", # Complex type: (61, source_id, ref_id) → list of structs 66: "List[bool]", + 77: "List[str]", 82: "List[enum]", # Complex type, needs source_id + enum_id 102: "f32", # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) @@ -130,10 +143,24 @@ def resolve_type_ids(type_ids: List[int]) -> List[str]: } # Type ID sets for categorization -_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} +_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 82, 102} _RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} _RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} -_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes +_COMPLEX_TYPE_IDS = {57, 60, 61, 64, 78, 81, 82, 85} # Types that need source_id + ref_id + +# HC_RESULT codes returned in COMMAND_EXCEPTION / STATUS_EXCEPTION (extend as observed) +_HC_RESULT_DESCRIPTIONS: Dict[int, str] = { + 0x0000: "success", + 0x0005: "invalid parameter / not supported", + 0x0006: "unknown command", + 0x0200: "hardware error", + 0x020A: "hardware not ready / axis error", +} + + +def describe_hc_result(code: int) -> str: + """Return human-readable description for an HC_RESULT code from device errors.""" + return _HC_RESULT_DESCRIPTIONS.get(code, f"HC_RESULT=0x{code:04X} (unknown)") def get_introspection_type_category(type_id: int) -> str: @@ -167,20 +194,6 @@ def resolve_introspection_type_name(type_id: int) -> str: return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") -def is_complex_introspection_type(type_id: int) -> bool: - """Check if introspection type is complex (needs additional bytes). - - Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id - - Args: - type_id: Introspection type ID - - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS - - # ============================================================================ # DATA STRUCTURES # ============================================================================ @@ -198,6 +211,62 @@ class ObjectInfo: children: Dict[str, "ObjectInfo"] = field(default_factory=dict) +@dataclass +class ParameterType: + """A resolved type reference from a method signature. + + Simple types (i8, f32, etc.) have only type_id set. + Complex types (struct, enum, List[struct], List[enum]) additionally have + source_id (the interface defining the struct/enum) and ref_id (struct_id + or enum_id within that interface). These are encoded as 3-byte triples + [type_id, source_id, ref_id] in the GetMethod response. + """ + + type_id: int + source_id: Optional[int] = None + ref_id: Optional[int] = None + + @property + def is_complex(self) -> bool: + return self.type_id in _COMPLEX_TYPE_IDS + + def resolve_name(self, registry: Optional["TypeRegistry"] = None) -> str: + """Resolve to a human-readable name, optionally using a TypeRegistry.""" + base = resolve_introspection_type_name(self.type_id) + if not self.is_complex or self.source_id is None or self.ref_id is None: + return base + if registry is None: + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if "struct" in base.lower(): + s = registry.resolve_struct(self.source_id, self.ref_id) + return s.name if s else f"{base}(iface={self.source_id}, id={self.ref_id})" + if "enum" in base.lower(): + e = registry.resolve_enum(self.source_id, self.ref_id) + return e.name if e else f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_type_ids(raw: str) -> List[ParameterType]: + """Parse the parameter_types string from GetMethod into ParameterType list. + + Simple types are 1 byte each. Complex types (struct, enum references) are + 3-byte triples: [type_id, source_id, ref_id]. The _COMPLEX_TYPE_IDS set + identifies which type_ids consume 3 bytes. + """ + data = [ord(c) for c in raw] if raw else [] + result: List[ParameterType] = [] + i = 0 + while i < len(data): + tid = data[i] + if tid in _COMPLEX_TYPE_IDS and i + 2 < len(data): + result.append(ParameterType(tid, source_id=data[i + 1], ref_id=data[i + 2])) + i += 3 + else: + result.append(ParameterType(tid)) + i += 1 + return result + + @dataclass class MethodInfo: """Method signature from introspection.""" @@ -206,55 +275,43 @@ class MethodInfo: call_type: int method_id: int name: str - parameter_types: list[int] = field( - default_factory=list - ) # Decoded parameter type IDs (Argument category) - parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) - return_types: list[int] = field( - default_factory=list - ) # Decoded return type IDs (ReturnElement/ReturnValue category) - return_labels: list[str] = field(default_factory=list) # Return names (if available) - - def get_signature_string(self) -> str: - """Get method signature as a readable string.""" - # Decode parameter types to readable names - if self.parameter_types: - param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] + parameter_types: list[ParameterType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[ParameterType] = field(default_factory=list) + return_labels: list[str] = field(default_factory=list) - # If we have labels, use them; otherwise just show types + def get_signature_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get method signature as a readable string. + + If a TypeRegistry is provided, struct/enum references are resolved to + their names (e.g. PickupTipParameters instead of struct(iface=1, id=57)). + """ + if self.parameter_types: + param_type_names = [pt.resolve_name(registry) for pt in self.parameter_types] if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): - # Format as "param1: type1, param2: type2" params = [ f"{label}: {type_name}" for label, type_name in zip(self.parameter_labels, param_type_names) ] param_str = ", ".join(params) else: - # Just show types param_str = ", ".join(param_type_names) else: param_str = "void" - # Decode return types to readable names if self.return_types: - return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] - return_categories = [get_introspection_type_category(tid) for tid in self.return_types] - - # Format return based on category + return_type_names = [rt.resolve_name(registry) for rt in self.return_types] + return_categories = [get_introspection_type_category(rt.type_id) for rt in self.return_types] if any(cat == "ReturnElement" for cat in return_categories): - # Multiple return values → struct format if self.return_labels and len(self.return_labels) == len(return_type_names): - # Format as "{ label1: type1, label2: type2 }" returns = [ f"{label}: {type_name}" for label, type_name in zip(self.return_labels, return_type_names) ] return_str = f"{{ {', '.join(returns)} }}" else: - # Just show types return_str = f"{{ {', '.join(return_type_names)} }}" elif len(return_type_names) == 1: - # Single return value if self.return_labels and len(self.return_labels) == 1: return_str = f"{self.return_labels[0]}: {return_type_names[0]}" else: @@ -267,6 +324,60 @@ def get_signature_string(self) -> str: return f"{self.name}({param_str}) -> {return_str}" +@dataclass +class TypeRegistry: + """Resolved type information for one object. + + Built once from introspection during setup. Caches structs, enums, and + interface info so method signatures can be fully resolved without additional + device calls. Use build_type_registry() to create. + + Example: + registry = await intro.build_type_registry(mph_addr) + method = registry.get_method(interface_id=1, method_id=9) + print(method.get_signature_string(registry)) # PickupTips(tipParameters: PickupTipParameters, ...) + """ + + address: Address + interfaces: Dict[int, "InterfaceInfo"] = field(default_factory=dict) + structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) + enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) + methods: List[MethodInfo] = field(default_factory=list) + + def resolve_struct(self, interface_id: int, struct_id: int) -> Optional["StructInfo"]: + """Look up a struct by interface_id and struct_id.""" + return self.structs.get(interface_id, {}).get(struct_id) + + def resolve_enum(self, interface_id: int, enum_id: int) -> Optional["EnumInfo"]: + """Look up an enum by interface_id and enum_id.""" + return self.enums.get(interface_id, {}).get(enum_id) + + def get_method(self, interface_id: int, method_id: int) -> Optional[MethodInfo]: + """Find a method by interface_id and method_id.""" + for m in self.methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + def get_interface_ids(self) -> Set[int]: + """Return the set of interface IDs this object implements.""" + return set(self.interfaces.keys()) + + def print_summary(self) -> None: + """Print a summary of all interfaces, structs, enums, and methods.""" + print(f"TypeRegistry for {self.address}") + print(f" Interfaces: {sorted(self.interfaces.keys())}") + for iid, iface in sorted(self.interfaces.items()): + n_structs = len(self.structs.get(iid, {})) + n_enums = len(self.enums.get(iid, {})) + n_methods = sum(1 for m in self.methods if m.interface_id == iid) + print(f" [{iid}] {iface.name}: {n_structs} structs, {n_enums} enums, {n_methods} methods") + for sid, s in sorted(self.structs.get(iid, {}).items()): + print(f" struct {sid}: {s.name} ({len(s.fields)} fields)") + for eid, e in sorted(self.enums.get(iid, {}).items()): + print(f" enum {eid}: {e.name} ({len(e.values)} values)") + + @dataclass class InterfaceInfo: """Interface metadata from introspection.""" @@ -307,52 +418,12 @@ def get_struct_string(self) -> str: return f"struct {self.name} {{\n {fields_str}\n}}" -# ============================================================================ -# WIRE STRUCTS FOR INTROSPECTION RESPONSES -# ============================================================================ - - -@dataclass(frozen=True) -class _InterfaceRowWire: - """One row of GetInterfaces response: interface_id, name, version.""" - - interface_id: I32 - name: Str - version: I32 - - -@dataclass(frozen=True) -class _EnumValueWire: - """One enum value: name, value.""" - - value_name: Str - value_value: I32 - - -@dataclass(frozen=True) -class _EnumRowWire: - """One row of GetEnums response: enum_id, name, values (counted flat array).""" - - enum_id: I32 - name: Str - values: Annotated[list[_EnumValueWire], CountedFlatArray()] - - -@dataclass(frozen=True) -class _StructFieldWire: - """One struct field: field_name, field_type.""" - - field_name: Str - field_type: I32 - - -@dataclass(frozen=True) -class _StructRowWire: - """One row of GetStructs response: struct_id, name, fields (counted flat array).""" - - struct_id: I32 - name: Str - fields: Annotated[list[_StructFieldWire], CountedFlatArray()] +# GetStructs wire format (device sends 4 separate array fragments, not count+rows): +# [0] STRING_ARRAY = struct names (one per struct) +# [1] U32_ARRAY = struct IDs +# [2] U8_ARRAY = field type IDs (flat across all structs) +# [3] STRING_ARRAY = field names (flat across all structs) +# Fields are split across structs by dividing evenly (e.g. 7 fields, 2 structs -> 3 + 4). # ============================================================================ @@ -405,10 +476,9 @@ def parse_response_parameters(cls, data: bytes) -> dict: _, method_id = parser.parse_next() _, name = parser.parse_next() - # The remaining fragments are STRING types containing type IDs as bytes - # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) - # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) - # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs) are 3-byte triples [type_id, source_id, ref_id]. + # Labels are comma-separated, one per *logical* parameter (matching ParameterType count). parameter_types_str = None parameter_labels_str = None @@ -418,38 +488,31 @@ def parse_response_parameters(cls, data: bytes) -> dict: if parser.has_remaining(): _, parameter_labels_str = parser.parse_next() - # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) - all_type_ids: list[int] = [] - if parameter_types_str: - all_type_ids = [ord(c) for c in parameter_types_str] + all_types = _parse_type_ids(parameter_types_str) if parameter_types_str else [] - # Parse all labels (comma-separated - includes both parameters and returns) all_labels: list[str] = [] if parameter_labels_str: all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] - # Categorize by type ID ranges (like piglet does) - # Split into arguments vs returns based on type ID category - parameter_types: list[int] = [] + parameter_types: list[ParameterType] = [] parameter_labels: list[str] = [] - return_types: list[int] = [] + return_types: list[ParameterType] = [] return_labels: list[str] = [] - for i, type_id in enumerate(all_type_ids): - category = get_introspection_type_category(type_id) + for i, pt in enumerate(all_types): + category = get_introspection_type_category(pt.type_id) label = all_labels[i] if i < len(all_labels) else None if category == "Argument": - parameter_types.append(type_id) + parameter_types.append(pt) if label: parameter_labels.append(label) elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) + return_types.append(pt) if label: return_labels.append(label) - # Unknown types - could be parameters or returns, default to parameters else: - parameter_types.append(type_id) + parameter_types.append(pt) if label: parameter_labels.append(label) @@ -458,10 +521,10 @@ def parse_response_parameters(cls, data: bytes) -> dict: "call_type": call_type, "method_id": method_id, "name": name, - "parameter_types": parameter_types, # Decoded type IDs (Argument category only) - "parameter_labels": parameter_labels, # Parameter names only - "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) - "return_labels": return_labels, # Return names only + "parameter_types": parameter_types, + "parameter_labels": parameter_labels, + "return_types": return_types, + "return_labels": return_labels, } @@ -489,7 +552,11 @@ class Response: class GetInterfacesCommand(HamiltonCommand): - """Get available interfaces (command_id=4).""" + """Get available interfaces (command_id=4). + + Firmware signature: InterfaceDescriptors(()) -> interfaceIds: I8_ARRAY, interfaceDescriptors: STRING_ARRAY + Returns 2 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -501,11 +568,18 @@ def __init__(self, object_address: Address): @dataclass(frozen=True) class Response: - interfaces: Annotated[list[_InterfaceRowWire], CountedFlatArray()] + interface_ids: I8Array + interface_names: StrArray class GetEnumsCommand(HamiltonCommand): - """Get enum definitions (command_id=5).""" + """Get enum definitions (command_id=5). + + Firmware signature: EnumInfo(interfaceId) -> enumerationNames: STRING_ARRAY, + numberEnumerationValues: U32_ARRAY, enumerationValues: I32_ARRAY, + enumerationValueDescriptions: STRING_ARRAY + Returns 4 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -522,7 +596,10 @@ def build_parameters(self) -> HoiParams: @dataclass(frozen=True) class Response: - enums: Annotated[list[_EnumRowWire], CountedFlatArray()] + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray class GetStructsCommand(HamiltonCommand): @@ -543,7 +620,12 @@ def build_parameters(self) -> HoiParams: @dataclass(frozen=True) class Response: - structs: Annotated[list[_StructRowWire], CountedFlatArray()] + """GetStructs returns 4 fragments: struct names, struct IDs, field type IDs, field names.""" + + struct_names: StrArray + struct_ids: U32Array + field_type_ids: U8Array + field_names: StrArray # ============================================================================ @@ -562,6 +644,12 @@ def __init__(self, backend): """ self.backend = backend + def _resolve_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve dot-path string to Address using the backend's registry, or return Address as-is.""" + if isinstance(addr_or_path, str): + return self.backend._registry.address(addr_or_path) + return addr_or_path + async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -628,6 +716,9 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: """Get available interfaces. + The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and + interface_names (STRING_ARRAY). + Args: address: Object address @@ -639,18 +730,24 @@ async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: if response is None: raise RuntimeError("GetInterfacesCommand returned None") + ids = list(response.interface_ids) + names = list(response.interface_names) return [ InterfaceInfo( - interface_id=int(iface.interface_id), - name=iface.name, - version=str(iface.version), + interface_id=int(ids[i]), + name=names[i] if i < len(names) else f"Interface_{ids[i]}", + version="", ) - for iface in response.interfaces + for i in range(len(ids)) ] async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: """Get enum definitions. + The device returns 4 columnar fragments: enum_names (STRING_ARRAY), + value_counts (U32_ARRAY), values (I32_ARRAY), value_names (STRING_ARRAY). + Values/names are split across enums using the value_counts. + Args: address: Object address interface_id: Interface ID @@ -663,18 +760,51 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] if response is None: raise RuntimeError("GetEnumsCommand returned None") - return [ - EnumInfo( - enum_id=int(enum_def.enum_id), - name=enum_def.name, - values={v.value_name: int(v.value_value) for v in enum_def.values}, - ) - for enum_def in response.enums - ] + enum_names = list(response.enum_names) + value_counts = list(response.value_counts) + all_values = list(response.values) + all_value_names = list(response.value_names) + n_enums = len(enum_names) + if n_enums == 0: + return [] + offset = 0 + result: List[EnumInfo] = [] + for i in range(n_enums): + cnt = int(value_counts[i]) if i < len(value_counts) else 0 + names_slice = all_value_names[offset : offset + cnt] + values_slice = all_values[offset : offset + cnt] + vals = dict(zip(names_slice, values_slice)) + result.append(EnumInfo(enum_id=i, name=enum_names[i], values=vals)) + offset += cnt + return result + + async def get_structs_raw( + self, address: Address, interface_id: int + ) -> tuple[bytes, List[dict]]: + """Get raw GetStructs response bytes and a fragment-by-fragment breakdown. + + Use this to see exactly what the device sends so response parsing can + match the wire format. Returns (params_bytes, inspect_hoi_params(params)). + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + command = GetStructsCommand(address, interface_id) + result = await self.backend.send_command( + command, ensure_connection=False, return_raw=True + ) + (params,) = result + return params, inspect_hoi_params(params) async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: """Get struct definitions. + The device returns 4 fragments: struct_names (StrArray), struct_ids (U32Array), + field_type_ids (U8Array), field_names (StrArray). Fields are split across + structs in order (even split when not divisible). + Args: address: Object address interface_id: Interface ID @@ -687,14 +817,33 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI if response is None: raise RuntimeError("GetStructsCommand returned None") - return [ - StructInfo( - struct_id=int(struct_def.struct_id), - name=struct_def.name, - fields={f.field_name: int(f.field_type) for f in struct_def.fields}, - ) - for struct_def in response.structs + struct_names = list(response.struct_names) + struct_ids = list(response.struct_ids) + field_type_ids = list(response.field_type_ids) + field_names = list(response.field_names) + n_structs = len(struct_ids) + n_fields = len(field_names) + if n_structs == 0: + return [] + # Split field names/type IDs across structs (even split) + counts = [ + n_fields // n_structs + (1 if i < n_fields % n_structs else 0) + for i in range(n_structs) ] + offset = 0 + result: List[StructInfo] = [] + for i in range(n_structs): + cnt = counts[i] + name = struct_names[i] if i < len(struct_names) else f"Struct_{struct_ids[i]}" + # field_type_ids may have one extra (e.g. 8 for 7 names); use min to stay in range + types_slice = field_type_ids[offset : offset + cnt] + names_slice = field_names[offset : offset + cnt] + fields = dict(zip(names_slice, types_slice)) + result.append( + StructInfo(struct_id=int(struct_ids[i]), name=name, fields=fields) + ) + offset += cnt + return result async def get_all_methods(self, address: Address) -> List[MethodInfo]: """Get all methods for an object. @@ -718,115 +867,274 @@ async def get_all_methods(self, address: Address) -> List[MethodInfo]: return methods - async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: - """Recursively discover object hierarchy. + async def build_type_registry(self, address: Union[Address, str]) -> TypeRegistry: + """Build a complete TypeRegistry for an object. + + Uses InterfaceDescriptors (get_interfaces) as the canonical source of + interface IDs; then queries structs and enums only for those interfaces. + No probing or fallback from method-derived interface IDs. Args: - root_address: Root object address + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). Returns: - Nested dictionary of discovered objects + TypeRegistry with all type information for this object """ - hierarchy = {} + address = self._resolve_address(address) + registry = TypeRegistry(address=address) + + # Canonical interface list — InterfaceDescriptors (command id=4) + interfaces = await self.get_interfaces(address) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + + # Methods — query separately; don't use for interface discovery + registry.methods = await self.get_all_methods(address) + + # Structs + enums for each declared interface + for iface in interfaces: + structs = await self.get_structs(address, iface.interface_id) + if structs: + registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} + enums = await self.get_enums(address, iface.interface_id) + if enums: + registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} + + return registry + + async def build_type_registry_with_children( + self, + address: Union[Address, str], + subobject_addresses: Optional[List[Address]] = None, + ) -> TypeRegistry: + """Build a TypeRegistry that includes structs/enums from child objects. + + Complex type references (e.g. type_57 = PickupTipParameters) may be + defined on a child object's interface rather than the parent. This method + builds the parent's registry, then merges in types from each child so + that ParameterType.resolve_name() can find them. - try: - # Get root object info - root_info = await self.get_object(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["info"] = root_info # type: ignore[assignment] + Args: + address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + subobject_addresses: Optional list of child addresses to include. + If None, all direct subobjects are discovered automatically. + + Returns: + TypeRegistry that can resolve types from both parent and children. + """ + address = self._resolve_address(address) + registry = await self.build_type_registry(address) - # Discover subobjects - subobjects = {} - for i in range(root_info.subobject_count): + if subobject_addresses is None: + obj_info = await self.get_object(address) + subobject_addresses = [] + for i in range(obj_info.subobject_count): try: - subaddress = await self.get_subobject_address(root_address, i) - subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) - except Exception as e: - logger.warning(f"Failed to discover subobject {i}: {e}") + sub_addr = await self.get_subobject_address(address, i) + subobject_addresses.append(sub_addr) + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] + for sub_addr in subobject_addresses: + try: + child_reg = await self.build_type_registry(sub_addr) + for iid, struct_map in child_reg.structs.items(): + registry.structs.setdefault(iid, {}).update(struct_map) + for iid, enum_map in child_reg.enums.items(): + registry.enums.setdefault(iid, {}).update(enum_map) + except Exception as e: + logger.debug("build_type_registry failed for child %s: %s", sub_addr, e) - # Discover methods - methods = await self.get_all_methods(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["methods"] = methods # type: ignore[assignment] + return registry - except Exception as e: - logger.error(f"Failed to discover hierarchy for {root_address}: {e}") - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["error"] = str(e) # type: ignore[assignment] + async def get_method_by_id( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + ) -> Optional[MethodInfo]: + """Return the method with the given interface_id and method_id (action id). - return hierarchy + Use this when you get a COMMAND_EXCEPTION to see the expected parameter + names and types for the command that was rejected. Example:: - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. + intro = HamiltonIntrospection(backend.client) + method = await intro.get_method_by_id(mph_address, interface_id=1, method_id=9) + if method: + print("Expected parameters:", method.parameter_labels) + print("Signature:", method.get_signature_string()) Args: - root_addresses: List of root addresses to start discovery from + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + interface_id: Interface ID (e.g. 1 for IChannel/IMph). + method_id: Method/command ID (e.g. 9 for PickupTips). Returns: - Dictionary mapping address strings to discovered hierarchies + MethodInfo for the matching method, or None if not found. """ - all_objects = {} - - for root_address in root_addresses: - try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy - except Exception as e: - logger.error(f"Failed to discover objects from {root_address}: {e}") - all_objects[str(root_address)] = {"error": str(e)} + address = self._resolve_address(address) + methods = await self.get_all_methods(address) + for m in methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + async def resolve_signature( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> str: + """One-liner: return a fully resolved method signature string. + + Looks up the method and resolves struct/enum references using the + provided TypeRegistry (or falls back to unresolved names). + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, mph_registry) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... - return all_objects - - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. + Returns: + Human-readable signature string, or a descriptive error string. + """ + address = self._resolve_address(address) + method = await self.get_method_by_id(address, interface_id, method_id) + if method is None: + return f"" + return method.get_signature_string(registry) + + async def resolve_error( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + error_text: str = "", + hc_result: Optional[int] = None, + ) -> str: + """Build an informative error diagnostic from a COMMAND_EXCEPTION. + + Resolves the object path, method signature, and expected parameters + so the user can see exactly what the firmware expected. When + hc_result is provided, appends a human-readable device error line + via describe_hc_result(). + + Example:: + + info = await intro.resolve_error(addr, 1, 9, mph_registry, hc_result=0x0005) + print(info) + # Error on MLPrepRoot.MphRoot.MPH (57345:1:4352) + # Method [1:9] PickupTips(tipParameters: PickupTipParameters, ...) + # Expected params: tipParameters, finalZ, tipDefinition, ... + # Device error: invalid parameter / not supported (HC_RESULT=0x0005) Args: - methods: List of MethodInfo objects to print + address: Object address or dot-path from the error. + interface_id: Interface ID from the error. + method_id: Method/command ID from the error. + registry: Optional TypeRegistry for resolving struct/enum names. + error_text: Raw error string (used when hc_result not provided). + hc_result: HC_RESULT code from the device (e.g. 0x0005); if set, shown via describe_hc_result(). + + Returns: + Multi-line diagnostic string. """ - print("Method Signatures:") - print("=" * 50) - for method in methods: - print(f" {method.get_signature_string()}") - print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") - print() + address = self._resolve_address(address) + lines: list[str] = [] - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. + path = self.backend._registry.path(address) if hasattr(self.backend, "_registry") else None + if path: + lines.append(f"Error on {path} ({address})") + else: + lines.append(f"Error on {address}") + + method = await self.get_method_by_id(address, interface_id, method_id) + if method: + sig = method.get_signature_string(registry) + lines.append(f" Method [{interface_id}:{method_id}] {sig}") + if method.parameter_labels: + lines.append(f" Expected params: {', '.join(method.parameter_labels)}") + for pt in method.parameter_types: + if pt.is_complex: + resolved = pt.resolve_name(registry) + lines.append(f" Param type {pt.type_id}: {resolved}") + else: + lines.append(f" Method [{interface_id}:{method_id}] ") - Args: - structs: List of StructInfo objects to print - """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() + if hc_result is not None: + lines.append(f" Device error: {describe_hc_result(hc_result)}") + elif error_text: + lines.append(f" Device said: {error_text}") - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. + return "\n".join(lines) - Args: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) + @staticmethod + def parse_error_address( + error_string: str, + ) -> Optional[tuple[Address, int, int, int]]: + """Extract (address, interface_id, method_id, hc_result) from a COMMAND_EXCEPTION string. + + The Hamilton error string format includes the address as + ``0xMMMM.0xNNNN.0xOOOO:0xII,0xCCCC,0xRRRR`` where the first part is + the 3-part object address and II/CCCC/RRRR encode interface, command, and HC_RESULT. + + Example:: + + result = HamiltonIntrospection.parse_error_address( + "0x0001.0x0001.0x1100:0x01,0x0009,0x020A" + ) + if result: + addr, iface_id, method_id, hc_result = result Returns: - List of methods matching the name pattern + (Address, interface_id, method_id, hc_result) or None if parsing fails. """ - return [method for method in methods if name_pattern.lower() in method.name.lower()] + import re - def get_methods_by_interface( - self, methods: List[MethodInfo], interface_id: int - ) -> List[MethodInfo]: - """Filter methods by interface ID. + m = re.search( + r"0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)" + r":0x([0-9a-fA-F]+),0x([0-9a-fA-F]+)(?:,0x([0-9a-fA-F]+))?", + error_string, + ) + if not m: + return None + module_id = int(m.group(1), 16) + node_id = int(m.group(2), 16) + object_id = int(m.group(3), 16) + interface_id = int(m.group(4), 16) + method_id = int(m.group(5), 16) + hc_result = int(m.group(6), 16) if m.group(6) else 0 + return Address(module_id, node_id, object_id), interface_id, method_id, hc_result + + async def diagnose_error( + self, + error_string: str, + registry: Optional[TypeRegistry] = None, + ) -> str: + """One-liner: parse a COMMAND_EXCEPTION string and return a full diagnostic. + + Combines parse_error_address() + resolve_error() into a single call. + Pass the raw error text (e.g. from a RuntimeError message) and get + back a human-readable diagnostic. + + Example:: - Args: - methods: List of MethodInfo objects to filter - interface_id: Interface ID to filter by + try: + await backend.send_command(cmd) + except RuntimeError as e: + print(await intro.diagnose_error(str(e), mph_registry)) Returns: - List of methods from the specified interface + Multi-line diagnostic string, or the original error if parsing fails. """ - return [method for method in methods if method.interface_id == interface_id] + parsed = self.parse_error_address(error_string) + if parsed is None: + return f"Could not parse error address from: {error_string}" + address, interface_id, method_id, hc_result = parsed + return await self.resolve_error( + address, interface_id, method_id, + registry=registry, hc_result=hc_result, + ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index f3b5f9a498a..bbfd8e22fb2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import dataclass, fields as dc_fields -from typing import Any, get_args, get_origin, get_type_hints +from typing import Any, List, get_args, get_origin, get_type_hints from pylabrobot.io.binary import Reader, Writer from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( @@ -136,6 +136,11 @@ class HoiParamsParser: """ def __init__(self, data: bytes): + if not isinstance(data, bytes): + raise TypeError( + f"HoiParamsParser requires bytes, got {type(data).__name__}. " + "Use get_structs_raw() and inspect_hoi_params() to see the wire format." + ) self._data = data self._offset = 0 @@ -163,6 +168,105 @@ def parse_all(self) -> list[tuple[int, Any]]: return results +def inspect_hoi_params(params: bytes) -> List[dict]: + """Inspect raw HOI params bytes fragment-by-fragment for debugging. + + Walks the DataFragment stream [type_id:1][flags:1][length:2][data:N] and + returns a list of dicts with: type_id, flags, length, payload_hex (first 80 + chars), payload_len, decoded (decode_fragment result or exception message). + Use this to see exactly what the device sends and fix response parsing. + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + if not params: + return [] + out: List[dict] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + flags = params[offset + 1] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + out.append({ + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": "", + "payload_len": 0, + "decoded": f"", + }) + break + data = params[offset + 4 : payload_end] + hex_preview = data.hex() if len(data) <= 40 else data[:40].hex() + "..." + try: + decoded = decode_fragment(type_id, data) + if isinstance(decoded, bytes): + decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" + decoded_repr = repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + if isinstance(decoded, list): + decoded_repr = f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + except Exception as e: + decoded_repr = f"" + out.append({ + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": hex_preview, + "payload_len": len(data), + "decoded": decoded_repr, + }) + offset = payload_end + return out + + +def parse_hamilton_error_params(params: bytes) -> str: + """Extract a human-readable message from HOI exception params. + + Hamilton COMMAND_EXCEPTION / STATUS_EXCEPTION responses send params as a + sequence of DataFragments. Often the first or second fragment is a STRING + (type_id=15) with a message like "0xE001.0x0001.0x1100:0x01,0x009,0x020A". + This walks the fragment stream, decodes all fragments, and returns a + single string (so you can see error codes and the message). If parsing + fails, returns a safe fallback (hex or generic message). + """ + parts = _parse_hamilton_error_fragments(params) + if not parts: + return params.hex() if params else "(empty)" + return "; ".join(parts) + + +def _parse_hamilton_error_fragments(params: bytes) -> List[str]: + """Decode all DataFragments in exception params. Returns list of "type: value" strings.""" + if not params: + return [] + out: List[str] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + break + data = params[offset + 4 : payload_end] + try: + decoded = decode_fragment(type_id, data) + try: + type_name = HamiltonDataType(type_id).name + except ValueError: + type_name = f"type_{type_id}" + if isinstance(decoded, bytes): + decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00").strip() + out.append(f"{type_name}={decoded}") + except Exception: + out.append(f"type_{type_id}=<{length} bytes>") + offset = payload_end + return out + + def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: """Decode a sequence of DataFragments into a dataclass instance using its wire-type annotations. @@ -199,9 +303,23 @@ def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: continue if isinstance(meta, CountedFlatArray): - _, count = parser.parse_next() + _, raw = parser.parse_next() element_type = get_args(get_args(ann)[0])[0] - values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] + if isinstance(raw, list): + # Single fragment was STRUCTURE_ARRAY: list of payload bytes per element + if raw and not isinstance(raw[0], bytes): + raise ValueError( + f"CountedFlatArray decoded to list of {type(raw[0]).__name__}, expected " + "list of bytes (STRUCTURE_ARRAY). Use get_structs_raw() and " + "inspect_hoi_params() to see the exact wire format." + ) + values[f.name] = [ + parse_into_struct(HoiParamsParser(p), element_type) for p in raw + ] + else: + # Count then N flat fragments (count-prefixed stream) + count = int(raw) + values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] continue type_id, value = parser.parse_next() diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 7b33649821a..844a3fe7796 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -1,7 +1,33 @@ -"""Hamilton TCP Backend Base Class. +"""Hamilton TCP communication layer. -This module provides the base backend for all Hamilton TCP instruments. -It handles connection management, message routing, and the introspection API. +HamiltonTCPClient +----------------- +Standalone, instrument-agnostic TCP transport for Hamilton HOI/HARP protocol. +Use directly in notebooks/scripts for discovery, introspection, and firmware +interaction without a LiquidHandler. Also composed by instrument backends as +``self.client``. + +Usage (standalone):: + + client = HamiltonTCPClient(host="192.168.100.102") + await client.setup() + intro = HamiltonIntrospection(client) + registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") + +Usage (in backends):: + + self.client = HamiltonTCPClient(host=host, port=port) + await self.client.setup() + await self.client.send_command(SomeCommand(...)) + +Error handling: By default (detailed_errors=True), command failures include +Layer A (HC_RESULT enum description) and Layer B (async method signature / +parameter diagnosis). Set detailed_errors=False to skip Layer B. + +Key classes +----------- +- ObjectRegistry: maps dot-path strings to Address (e.g. "MLPrepRoot.MphRoot.MPH") +- RegistryProxy: dot-syntax accessor (client.interfaces.MLPrepRoot.MphRoot.MPH.address) """ from __future__ import annotations @@ -13,16 +39,18 @@ from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( HamiltonIntrospection, ObjectInfo, + TypeRegistry, + describe_hc_result, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, InitResponse, + parse_hamilton_error_params, RegistrationMessage, RegistrationResponse, ) @@ -40,8 +68,8 @@ class ObjectRegistry: """Maps object paths to addresses. Depth-1 eager by default; lazy resolution beyond.""" - def __init__(self, backend: "HamiltonTCPBackend"): - self._backend = backend + def __init__(self, transport: "HamiltonTCPClient"): + self._transport = transport self._objects: Dict[str, ObjectInfo] = {} self._root_addresses: List[Address] = [] @@ -70,6 +98,21 @@ def address(self, path: str) -> Address: raise KeyError(f"Object '{path}' not discovered") return obj.address + def path(self, address: Address) -> Optional[str]: + """Return the registered object path for this address, or None if not in registry.""" + return self.find_path_by_address(address) + + def find_path_by_address(self, address: Address) -> Optional[str]: + """Return the registered object path for this address, or None if not in registry.""" + for path, obj in self._objects.items(): + if ( + obj.address.module == address.module + and obj.address.node == address.node + and obj.address.object == address.object + ): + return path + return None + async def resolve(self, path: str) -> Address: if path in self._objects: return self._objects[path].address @@ -78,7 +121,7 @@ async def resolve(self, path: str) -> Address: raise KeyError(f"Invalid path: '{path}'") parent_path = ".".join(parts[:-1]) child_name = parts[-1] - introspection = HamiltonIntrospection(self._backend) + introspection = HamiltonIntrospection(self._transport) if not parent_path: if not self._root_addresses: @@ -105,6 +148,83 @@ async def resolve(self, path: str) -> Address: raise KeyError(f"Child '{child_name}' not found under '{parent_path}'") +class RegistryProxy: + """Chainable dot-syntax accessor over ObjectRegistry. + + Routing: .address for required paths (KeyError if missing). Optional paths: .is_available + or a firmware probe, per interface. Depth-2+ paths: await .resolve() once before .address. + .info for metadata; __dir__ for tab-completion. + """ + + def __init__(self, registry: "ObjectRegistry", path: str = ""): + object.__setattr__(self, "_registry", registry) + object.__setattr__(self, "_path", path) + + def __getattr__(self, name: str) -> "RegistryProxy": + current = object.__getattribute__(self, "_path") + new_path = name if not current else f"{current}.{name}" + return RegistryProxy(object.__getattribute__(self, "_registry"), new_path) + + def __getitem__(self, name: str) -> "RegistryProxy": + """Support backend.interfaces['RootName'] or backend.interfaces['Root.Child'].""" + current = object.__getattribute__(self, "_path") + if not current: + new_path = name + elif "." in name: + new_path = name + else: + new_path = f"{current}.{name}" + return RegistryProxy(object.__getattribute__(self, "_registry"), new_path) + + def __dir__(self): + current = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + prefix = f"{current}." if current else "" + children = set() + for key in registry._objects: + if key.startswith(prefix): + segment = key[len(prefix) :].split(".")[0] + if segment: + children.add(segment) + return list(children) + + async def resolve(self) -> "RegistryProxy": + """Lazily resolve this path (depth-2+). Must be awaited once before .address is accessible.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + await registry.resolve(path) + return self + + @property + def address(self) -> Address: + """Wire-level destination for this path. Use for required interfaces; KeyError if not discovered.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + return registry.address(path) + + @property + def info(self) -> ObjectInfo: + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + obj = registry._objects.get(path) + if obj is None: + raise KeyError(f"'{path}' not in registry. Call await .resolve() first.") + return obj + + @property + def is_available(self) -> bool: + """True if this path was discovered and is present in the registry.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + return path in registry._objects + + def __repr__(self) -> str: + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + registered = path in registry._objects + return f"" + + @dataclass class HamiltonError: """Hamilton error response.""" @@ -135,21 +255,15 @@ def parse_error(data: bytes) -> HamiltonError: ) -class HamiltonTCPBackend(LiquidHandlerBackend): - """Base backend for all Hamilton TCP instruments. - - Hamilton TCP instruments include the Nimbus and the Prep, using Hoi and Harp. - STAR and Vantage use the other Hamilton protocol that works over USB. - - This class provides: - - Connection management via Socket (wrapped with state tracking) - - Protocol 7 initialization - - Protocol 3 registration - - Generic command execution - - Object discovery via introspection +class HamiltonTCPClient: + """Hamilton TCP communication and introspection (instrument-agnostic). - Hamilton uses strict request-response protocol (no unsolicited messages), - so we use simple direct read/write instead of complex routing. + Handles connection, Protocol 7/3, discovery, object registry, and command + execution. Use standalone for discovery notebooks or assign to + self.client in PrepBackend/NimbusBackend. Does not implement liquid-handling. + interfaces (RegistryProxy): .address for required paths; .is_available or + firmware probe for optional; await .resolve() for depth-2+ paths. + detailed_errors=True (default) enables full diagnosis on command failure. """ def __init__( @@ -160,37 +274,39 @@ def __init__( write_timeout: float = 30.0, auto_reconnect: bool = True, max_reconnect_attempts: int = 3, + detailed_errors: bool = True, ): - """Initialize Hamilton TCP backend. - - Args: - host: Hamilton instrument IP address - port: Hamilton instrument port (usually 50007) - read_timeout: Read timeout in seconds - write_timeout: Write timeout in seconds - auto_reconnect: Enable automatic reconnection - max_reconnect_attempts: Maximum reconnection attempts - """ - super().__init__() - self.io = Socket( host=host, port=port, read_timeout=read_timeout, write_timeout=write_timeout, ) - - # Connection state tracking (wrapping Socket) self._connected = False self._reconnect_attempts = 0 self.auto_reconnect = auto_reconnect self.max_reconnect_attempts = max_reconnect_attempts - - # Hamilton-specific state + self.detailed_errors = detailed_errors self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} self._registry = ObjectRegistry(self) + self._type_registries: Dict[Address, TypeRegistry] = {} + + @property + def interfaces(self) -> RegistryProxy: + """Dot-syntax access to the discovered object registry.""" + return RegistryProxy(self._registry) + + def discovered_root_name(self) -> str: + """Return the root interface name (e.g. NimbusCORE, MLPrepRoot). + + Valid after setup(); use in backends to validate instrument type. + """ + if not self._registry._objects: + raise RuntimeError("No objects discovered. Call setup() first.") + first_key = next(iter(self._registry._objects.keys())) + return first_key.split(".")[0] async def _ensure_connected(self): """Ensure connection is healthy before operations.""" @@ -623,7 +739,9 @@ async def send_command( self, command: HamiltonCommand, ensure_connection: bool = True, - ) -> Optional[Any]: + return_raw: bool = False, + raise_on_error: bool = True, + ) -> Any: """Send Hamilton command and wait for response. Sets source_address if not already set by caller (for testing). @@ -635,20 +753,28 @@ async def send_command( once with no retry. Read/write timeouts are enforced at the backend level (read_timeout and - write_timeout passed into HamiltonTCPBackend and used by the Socket). + write_timeout passed into HamiltonTCPClient and used by the Socket). Args: command: Hamilton command to execute. ensure_connection: If True, reconnect and retry once on connection error. If False, send once (for setup/discovery). + return_raw: If True, return (params_bytes,) instead of parsing the + response. Use with inspect_hoi_params() to debug wire format. + raise_on_error: If True (default), log ERROR and raise on STATUS_EXCEPTION + / COMMAND_EXCEPTION. If False, log DEBUG and return None (for probing + many object/interface pairs without log spam). Returns: - Parsed response (Command.Response instance, dict, or None). + If return_raw=True: (params_bytes,). Otherwise parsed response + (Command.Response instance, dict, or None). None if raise_on_error=False + and the device returned an exception action. Raises: ConnectionError: If the connection is not established and auto_reconnect is disabled, or if reconnection fails. - RuntimeError: If the Hamilton firmware returns an error action code. + RuntimeError: If the Hamilton firmware returns an error action code and + raise_on_error is True. """ connection_errors = ( BrokenPipeError, @@ -684,10 +810,30 @@ async def send_command( Hoi2Action.COMMAND_EXCEPTION, Hoi2Action.INVALID_ACTION_RESPONSE, ): - error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" - logger.error(f"Hamilton error {action}: {error_message}") - raise RuntimeError(f"Hamilton error {action}: {error_message}") - + parsed = parse_hamilton_error_params(response_message.hoi.params) + # Layer A: always append HC_RESULT enum description (synchronous, no round-trips) + parsed_addr = HamiltonIntrospection.parse_error_address(parsed) + hc_suffix = f" [{describe_hc_result(parsed_addr[3])}]" if parsed_addr else "" + enriched_msg = f"Hamilton error {action.name} (action={action:#x}): {parsed}{hc_suffix}" + if raise_on_error: + logger.error(enriched_msg) + if not self.detailed_errors: + raise RuntimeError(enriched_msg) + # Layer B: async TypeRegistry diagnosis (method signature, expected params) + intro = HamiltonIntrospection(self) + addr = command.dest_address + if addr not in self._type_registries: + try: + self._type_registries[addr] = await intro.build_type_registry(addr) + except Exception: + raise RuntimeError(enriched_msg) + diagnostic = await intro.diagnose_error(enriched_msg, self._type_registries[addr]) + raise RuntimeError(diagnostic) + logger.debug(enriched_msg) + return None + + if return_raw: + return (response_message.hoi.params,) return command.interpret_response(response_message) except connection_errors as e: @@ -704,20 +850,20 @@ async def send_command( raise last_error async def stop(self): - """Stop the backend and close connection.""" + """Close connection.""" try: await self.io.stop() except Exception as e: logger.warning(f"Error during stop: {e}") finally: self._connected = False - self.setup_finished = False - logger.info("Hamilton backend stopped") + logger.info("Hamilton TCP client stopped") def serialize(self) -> dict: - """Serialize backend configuration.""" + """Serialize client configuration.""" return { - **super().serialize(), "client_id": self._client_id, "registry_paths": list(self._registry._objects.keys()), } + + From 3457272cf5fa35118d79988ca9abdeff6a259f3a Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:40:28 -0800 Subject: [PATCH 11/42] Make Introspection more polite. 1. Get suported methods before running introspection-related commands. Avoids calling unsupported methods which can lead to Large error streams if doing recursive/deep introspection into a new interface. --- .../backends/hamilton/tcp/introspection.py | 115 ++++++++++++++---- .../backends/hamilton/tcp/tcp_tests.py | 97 +++++++++++++++ .../backends/hamilton/tcp_backend.py | 26 +++- 3 files changed, 212 insertions(+), 26 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 7a54403ca4f..7e360e8718f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -628,13 +628,32 @@ class Response: field_names: StrArray +# ============================================================================ +# INTERFACE 0 METHOD IDS (Object Discovery / Introspection) +# ============================================================================ +# Used to guard calls: only call an Interface 0 method if it is in the set +# returned by get_supported_interface0_method_ids (from the object's method table). + +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 + + # ============================================================================ # HIGH-LEVEL INTROSPECTION API # ============================================================================ class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" + """High-level API for Hamilton introspection. + + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported and only calls those. Interfaces are per-object; + there is no aggregation from children. + """ def __init__(self, backend): """Initialize introspection API. @@ -650,6 +669,25 @@ def _resolve_address(self, addr_or_path: Union[Address, str]) -> Address: return self.backend._registry.address(addr_or_path) return addr_or_path + async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: + """Return the set of Interface 0 method IDs this object supports. + + Calls GetObject to get method_count, then GetMethod(address, i) for each + index and collects method_id for every method where interface_id == 0. + Used to guard calls so we never send an Interface 0 command the object + did not advertise. + """ + obj = await self.get_object(address) + supported: Set[int] = set() + for i in range(obj.method_count): + try: + method = await self.get_method(address, i) + if method.interface_id == 0: + supported.add(method.method_id) + except Exception as e: + logger.debug("get_method(%s, %d) failed: %s", address, i, e) + return supported + async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -717,7 +755,8 @@ async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: """Get available interfaces. The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and - interface_names (STRING_ARRAY). + interface_names (STRING_ARRAY). Returns [] if the object does not support + GetInterfaces (interface 0, method 4). Args: address: Object address @@ -725,6 +764,13 @@ async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: Returns: List of interface information """ + supported = await self.get_supported_interface0_method_ids(address) + if GET_INTERFACES not in supported: + logger.debug( + "Object at %s does not support GetInterfaces (interface 0, method 4); returning []", + address, + ) + return [] command = GetInterfacesCommand(address) response = await self.backend.send_command(command, ensure_connection=False) if response is None: @@ -848,14 +894,22 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI async def get_all_methods(self, address: Address) -> List[MethodInfo]: """Get all methods for an object. + Returns [] if the object does not support GetMethod (interface 0, method 2). + Args: address: Object address Returns: List of all method signatures """ - # First get object info to know how many methods there are object_info = await self.get_object(address) + supported = await self.get_supported_interface0_method_ids(address) + if GET_METHOD not in supported: + logger.debug( + "Object at %s does not support GetMethod (interface 0, method 2); returning []", + address, + ) + return [] methods = [] for i in range(object_info.method_count): @@ -872,7 +926,8 @@ async def build_type_registry(self, address: Union[Address, str]) -> TypeRegistr Uses InterfaceDescriptors (get_interfaces) as the canonical source of interface IDs; then queries structs and enums only for those interfaces. - No probing or fallback from method-derived interface IDs. + Only calls Interface 0 methods that the object supports; skips unsupported + commands and builds a partial registry. Args: address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). @@ -882,23 +937,29 @@ async def build_type_registry(self, address: Union[Address, str]) -> TypeRegistr """ address = self._resolve_address(address) registry = TypeRegistry(address=address) + supported = await self.get_supported_interface0_method_ids(address) - # Canonical interface list — InterfaceDescriptors (command id=4) - interfaces = await self.get_interfaces(address) - for iface in interfaces: - registry.interfaces[iface.interface_id] = iface + if GET_INTERFACES in supported: + interfaces = await self.get_interfaces(address) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + else: + interfaces = [] - # Methods — query separately; don't use for interface discovery - registry.methods = await self.get_all_methods(address) + if GET_METHOD in supported: + registry.methods = await self.get_all_methods(address) + else: + registry.methods = [] - # Structs + enums for each declared interface for iface in interfaces: - structs = await self.get_structs(address, iface.interface_id) - if structs: - registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} - enums = await self.get_enums(address, iface.interface_id) - if enums: - registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} + if GET_STRUCTS in supported: + structs = await self.get_structs(address, iface.interface_id) + if structs: + registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(address, iface.interface_id) + if enums: + registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} return registry @@ -926,14 +987,18 @@ async def build_type_registry_with_children( registry = await self.build_type_registry(address) if subobject_addresses is None: - obj_info = await self.get_object(address) - subobject_addresses = [] - for i in range(obj_info.subobject_count): - try: - sub_addr = await self.get_subobject_address(address, i) - subobject_addresses.append(sub_addr) - except Exception: - logger.debug("get_subobject_address(%d) failed for %s", i, address) + supported = await self.get_supported_interface0_method_ids(address) + if GET_SUBOBJECT_ADDRESS not in supported: + subobject_addresses = [] + else: + obj_info = await self.get_object(address) + subobject_addresses = [] + for i in range(obj_info.subobject_count): + try: + sub_addr = await self.get_subobject_address(address, i) + subobject_addresses.append(sub_addr) + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) for sub_addr in subobject_addresses: try: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 681effd71e9..6740dfbc443 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -8,6 +8,7 @@ import unittest from dataclasses import dataclass from typing import Annotated +from unittest.mock import AsyncMock from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( @@ -1345,5 +1346,101 @@ def test_get_object_command_interpret_response(self): self.assertEqual(result.subobject_count, 2) +class TestInterface0Capability(unittest.IsolatedAsyncioTestCase): + """Tests for Interface 0 capability checks and clean traversal.""" + + def test_interface0_constants(self): + """Interface 0 method ID constants match the introspection command IDs.""" + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + GET_ENUMS, + GET_INTERFACES, + GET_METHOD, + GET_OBJECT, + GET_STRUCTS, + GET_SUBOBJECT_ADDRESS, + ) + + self.assertEqual(GET_OBJECT, 1) + self.assertEqual(GET_METHOD, 2) + self.assertEqual(GET_SUBOBJECT_ADDRESS, 3) + self.assertEqual(GET_INTERFACES, 4) + self.assertEqual(GET_ENUMS, 5) + self.assertEqual(GET_STRUCTS, 6) + + async def test_get_supported_interface0_method_ids_returns_expected_set(self): + """get_supported_interface0_method_ids returns method_ids for interface 0 only.""" + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + GetMethodCommand, + GetObjectCommand, + HamiltonIntrospection, + ) + + addr = Address(1, 1, 100) + get_object_response = GetObjectCommand.Response( + name="Obj", + version="1.0", + method_count=2, + subobject_count=0, + ) + get_method_responses = [ + {"interface_id": 0, "method_id": 1, "call_type": 0, "name": "GetObject"}, + {"interface_id": 0, "method_id": 3, "call_type": 0, "name": "GetSubobjectAddress"}, + ] + + async def mock_send(cmd, **kwargs): + if isinstance(cmd, GetObjectCommand): + return get_object_response + if isinstance(cmd, GetMethodCommand): + idx = cmd.method_index + return get_method_responses[idx] if idx < len(get_method_responses) else {} + return None + + backend = AsyncMock() + backend.send_command = AsyncMock(side_effect=mock_send) + backend._registry = None + introspection = HamiltonIntrospection(backend) + result = await introspection.get_supported_interface0_method_ids(addr) + self.assertEqual(result, {1, 3}) + + async def test_resolve_raises_when_parent_does_not_support_get_subobject_address(self): + """resolve() raises KeyError with clear message when parent does not support (0, 3).""" + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + GetObjectCommand, + ObjectInfo, + ) + from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ObjectRegistry + + root_addr = Address(1, 1, 48896) + root_info = ObjectInfo( + name="Root", + version="1.0", + method_count=0, + subobject_count=1, + address=root_addr, + ) + + async def mock_send(cmd, **kwargs): + if isinstance(cmd, GetObjectCommand): + return GetObjectCommand.Response( + name="Root", + version="1.0", + method_count=0, + subobject_count=1, + ) + return None + + transport = AsyncMock() + transport.send_command = AsyncMock(side_effect=mock_send) + registry = ObjectRegistry(transport) + registry.set_root_addresses([root_addr]) + registry.register("Root", root_info) + + with self.assertRaises(KeyError) as ctx: + await registry.resolve("Root.Child") + self.assertIn("GetSubobjectAddress", str(ctx.exception)) + self.assertIn("interface 0, method 3", str(ctx.exception)) + self.assertIn("Child", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 844a3fe7796..7ddad01e933 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -41,6 +41,7 @@ from pylabrobot.io.socket import Socket from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + GET_SUBOBJECT_ADDRESS, HamiltonIntrospection, ObjectInfo, TypeRegistry, @@ -114,6 +115,12 @@ def find_path_by_address(self, address: Address) -> Optional[str]: return None async def resolve(self, path: str) -> Address: + """Resolve a dot-path to an Address, lazy-resolving and registering as needed. + + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported; only calls GetSubobjectAddress when the parent + supports it. Interfaces are per-object (no aggregation from children). + """ if path in self._objects: return self._objects[path].address parts = [p for p in path.split(".") if p] @@ -136,6 +143,12 @@ async def resolve(self, path: str) -> Address: parent_addr = await self.resolve(parent_path) parent_info = self._objects[parent_path] + supported = await introspection.get_supported_interface0_method_ids(parent_info.address) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"Object at path '{parent_path}' does not support GetSubobjectAddress " + f"(interface 0, method 3); cannot resolve child '{child_name}'" + ) for i in range(parent_info.subobject_count): sub_addr = await introspection.get_subobject_address(parent_info.address, i) sub_info = await introspection.get_object(sub_addr) @@ -658,13 +671,24 @@ async def _register_tree( parent_path: str, max_depth: int, ) -> None: - """Recursively register one node and its children up to max_depth.""" + """Recursively register one node and its children up to max_depth. + + Only calls GetSubobjectAddress when the object supports it (interface 0, + method 3); otherwise skips children to avoid unsupported-method errors. + """ info = await introspection.get_object(addr) info.children = {} path = info.name if not parent_path else f"{parent_path}.{info.name}" self._registry.register(path, info) if max_depth <= 0: return + supported = await introspection.get_supported_interface0_method_ids(addr) + if GET_SUBOBJECT_ADDRESS not in supported: + logger.debug( + "Object %s does not support GetSubobjectAddress (interface 0, method 3); skipping children", + path, + ) + return for i in range(info.subobject_count): try: sub_addr = await introspection.get_subobject_address(addr, i) From 84e7d1bc45ab7184768dced2c220e7ae33190ff0 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:58:51 -0800 Subject: [PATCH 12/42] Get Introspection Signature includes interface/command id --- .../liquid_handling/backends/hamilton/tcp/introspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 7e360e8718f..fb690f2345a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -321,7 +321,7 @@ def get_signature_string(self, registry: Optional["TypeRegistry"] = None) -> str else: return_str = "void" - return f"{self.name}({param_str}) -> {return_str}" + return f"[{self.interface_id}:{self.method_id}] {self.name}({param_str}) -> {return_str}" @dataclass From ff1b8b639f47d3e10efa219594cf32a547ff8580 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:47:03 -0800 Subject: [PATCH 13/42] Fix String and StringArray Protocol --- .../backends/hamilton/tcp/tcp_tests.py | 4 ++-- .../backends/hamilton/tcp/wire_types.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 6740dfbc443..a76762f83b6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -472,8 +472,8 @@ def test_bool_array(self): def test_string_array(self): params = HoiParams().add(["a", "bc"], StrArray).build() self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) - # String arrays have u32 count prefix - self.assertEqual(params[4:8], b"\x02\x00\x00\x00") # count = 2 + # String array payload is concatenated null-terminated strings (no count) + self.assertEqual(params[4:9], b"a\x00bc\x00") def test_method_chaining(self): self.assertEqual(HoiParams().add(1, I32).add("test", Str).add(True, Bool).count(), 3) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py index eccde720852..12544c751e7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py @@ -224,11 +224,16 @@ def encode_into(self, value, params: HoiParams) -> HoiParams: return params._add_fragment(self.type_id, data) def decode_from(self, data: bytes) -> Any: - return data.rstrip(b"\x00").decode("ascii") + return data.rstrip(b"\x00").decode("utf-8") class StringArrayType(WireType): - """Count-prefixed array of null-terminated strings (type_id=34).""" + """Array of null-terminated strings (type_id=34). + + Wire format: payload is a concatenation of null-terminated UTF-8 strings with + no leading element count. Fragment length in the HOI header defines the + payload boundary. + """ __slots__ = () @@ -236,18 +241,17 @@ def __init__(self): super().__init__(HamiltonDataType.STRING_ARRAY) def encode_into(self, value, params: HoiParams) -> HoiParams: - data = _struct.pack(" Any: - if len(data) < 4: + if not data: return [] - count = _struct.unpack(" Date: Wed, 4 Mar 2026 17:50:04 -0800 Subject: [PATCH 14/42] Fix Introspection's handling of complex types, and dynamic return shapes when running getStructs getEnums.... --- .../backends/hamilton/prep_backend.py | 1 + .../backends/hamilton/tcp/introspection.py | 647 ++++++++++++++++-- .../backends/hamilton/tcp/messages.py | 36 +- .../backends/hamilton/tcp/tcp_tests.py | 2 +- .../backends/hamilton/tcp/wire_types.py | 3 +- .../backends/hamilton/tcp_backend.py | 82 +++ 6 files changed, 695 insertions(+), 76 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index bd6d1e25fb0..e96228e64f1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -46,6 +46,7 @@ Enum as WEnum, ) from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend + from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.standard import ( Drop, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index fb690f2345a..feec86f3c88 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -28,6 +28,7 @@ from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + PADDED_FLAG, HoiParams, HoiParamsParser, inspect_hoi_params, @@ -82,6 +83,8 @@ def resolve_type_id(type_id: int) -> str: # - ReturnValue types: Single return value _INTROSPECTION_TYPE_NAMES: dict[int, str] = { + # Void (0) - used for empty/placeholder parameters + 0: "void", # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) 1: "i8", 2: "u8", @@ -146,7 +149,13 @@ def resolve_type_id(type_id: int) -> str: _ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 82, 102} _RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} _RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} -_COMPLEX_TYPE_IDS = {57, 60, 61, 64, 78, 81, 82, 85} # Types that need source_id + ref_id + +# Complex type sentinels: byte values that begin a 3-byte triple [type_id, source_id, ref_id]. +# The two contexts (method parameterTypes vs struct structureElementTypes) use different sentinels. +_COMPLEX_METHOD_TYPE_IDS = {57, 60, 61, 64, 78, 81, 82, 85} # GetMethod parameterTypes triples +_COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 +# Backward-compat alias (used by ParameterType.is_complex for method parameters) +_COMPLEX_TYPE_IDS = _COMPLEX_METHOD_TYPE_IDS # HC_RESULT codes returned in COMMAND_EXCEPTION / STATUS_EXCEPTION (extend as observed) _HC_RESULT_DESCRIPTIONS: Dict[int, str] = { @@ -213,22 +222,37 @@ class ObjectInfo: @dataclass class ParameterType: - """A resolved type reference from a method signature. + """A resolved type reference used for both method parameters and struct fields. Simple types (i8, f32, etc.) have only type_id set. - Complex types (struct, enum, List[struct], List[enum]) additionally have - source_id (the interface defining the struct/enum) and ref_id (struct_id - or enum_id within that interface). These are encoded as 3-byte triples - [type_id, source_id, ref_id] in the GetMethod response. + Complex references additionally carry source_id (the interface defining the + struct/enum) and ref_id (struct_id or enum_id within that interface). + These are encoded as 3-byte triples [type_id, source_id, ref_id] in two + distinct contexts that each use a different sentinel byte: + + - GetMethod parameterTypes: sentinels in _COMPLEX_METHOD_TYPE_IDS (57, 61 …) + - GetStructs structureElementTypes: sentinel 0xE8 (_COMPLEX_STRUCT_TYPE_IDS) """ type_id: int source_id: Optional[int] = None ref_id: Optional[int] = None + _byte_width: int = 1 # Bytes consumed in struct element_types (1=simple, 3=ref, 7+=inline) @property def is_complex(self) -> bool: - return self.type_id in _COMPLEX_TYPE_IDS + """True if this is a 3-byte complex reference (method param or struct field).""" + return self.type_id in (_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) + + @property + def is_struct_ref(self) -> bool: + """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" + return self.type_id in {30, 31, 57, 60, 61, 64} + + @property + def is_enum_ref(self) -> bool: + """True if this is an enum reference (type 32 in struct context, 78/81/82/85 in method).""" + return self.type_id in {32, 35, 78, 81, 82, 85} def resolve_name(self, registry: Optional["TypeRegistry"] = None) -> str: """Resolve to a human-readable name, optionally using a TypeRegistry.""" @@ -246,27 +270,78 @@ def resolve_name(self, registry: Optional["TypeRegistry"] = None) -> str: return f"{base}(iface={self.source_id}, id={self.ref_id})" -def _parse_type_ids(raw: str) -> List[ParameterType]: - """Parse the parameter_types string from GetMethod into ParameterType list. +def _parse_type_seq( + data: bytes | list[int], + complex_ids: set[int], +) -> List[ParameterType]: + """Shared variable-width parser for Hamilton type-ID byte sequences. + + Both GetMethod parameterTypes and GetStructs structureElementTypes encode types + as a byte stream where simple types occupy 1 byte and complex references have + variable width. + + For struct element types (complex_ids = _COMPLEX_STRUCT_TYPE_IDS), complex + sentinels (30=STRUCTURE, 31=STRUCT_ARRAY, 32=ENUM, 35=ENUM_ARRAY) have two + encoding formats determined by the second byte: - Simple types are 1 byte each. Complex types (struct, enum references) are - 3-byte triples: [type_id, source_id, ref_id]. The _COMPLEX_TYPE_IDS set - identifies which type_ids consume 3 bytes. + - **Reference** (second byte ≤ 3): 3 bytes ``[sentinel, source_id, ref_id]`` + where source 1=global, 2=local, 3=network. + - **Inline definition** (second byte = 4): variable width, terminated by + ``0xEE`` (238). Typically 7 bytes: ``[sentinel, 4, base_type, 0, 1, 0, 0xEE]``. + The ``base_type`` specifies the underlying wire type (1=I8, 2=I16, 3=I32). + + For method parameter types, only the 3-byte reference format is used. + + Args: + data: Raw bytes or list of ints to parse. + complex_ids: Set of type_id values that introduce a multi-byte entry. + + Returns: + List of ParameterType, one per logical type entry. """ - data = [ord(c) for c in raw] if raw else [] + _INLINE_MARKER = 4 + _INLINE_TERMINATOR = 0xEE # 238 + + ints = list(data) if isinstance(data, bytes) else data result: List[ParameterType] = [] i = 0 - while i < len(data): - tid = data[i] - if tid in _COMPLEX_TYPE_IDS and i + 2 < len(data): - result.append(ParameterType(tid, source_id=data[i + 1], ref_id=data[i + 2])) - i += 3 + while i < len(ints): + tid = ints[i] + if tid in complex_ids and i + 2 < len(ints): + second = ints[i + 1] + if second == _INLINE_MARKER: + # Inline type definition: scan forward to 0xEE terminator + end = i + 2 + while end < len(ints) and ints[end] != _INLINE_TERMINATOR: + end += 1 + end += 1 # consume the 0xEE byte itself + # Store as ParameterType with the base wire type from byte [i+2] + width = end - i + base_type = ints[i + 2] if i + 2 < len(ints) else 0 + result.append(ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width)) + i = end + else: + # Standard 3-byte reference: [sentinel, source_id, ref_id] + result.append(ParameterType(tid, source_id=second, ref_id=ints[i + 2], _byte_width=3)) + i += 3 else: result.append(ParameterType(tid)) i += 1 return result +def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_type_seq. + + Accepts bytes (preferred) or str — the device sends STRING (15) but the + payload is binary, so callers must use parse_next_raw() to avoid UTF-8 errors. + """ + if raw is None: + return [] + data: list[int] = list(raw) if isinstance(raw, bytes) else [ord(c) for c in raw] + return _parse_type_seq(data, _COMPLEX_METHOD_TYPE_IDS) + + @dataclass class MethodInfo: """Method signature from introspection.""" @@ -332,6 +407,11 @@ class TypeRegistry: interface info so method signatures can be fully resolved without additional device calls. Use build_type_registry() to create. + Source ID semantics (from piglet): + source_id=1: Global pool (shared type definitions from global objects) + source_id=2: Local to the current object's interface + source_id=3: Built-in NetworkResult error type + Example: registry = await intro.build_type_registry(mph_addr) method = registry.get_method(interface_id=1, method_id=9) @@ -343,14 +423,28 @@ class TypeRegistry: structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) methods: List[MethodInfo] = field(default_factory=list) + global_pool: Optional["GlobalTypePool"] = None + + def resolve_struct(self, source_id: int, ref_id: int) -> Optional["StructInfo"]: + """Look up a struct by source_id and ref_id. + + source_id=1: Global pool (1-based index, piglet subtracts 1) + source_id=2: Local interface structs (keyed by interface_id in self.structs) + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_struct(ref_id) + # source_id=2 or fallback: treat source_id as interface_id + return self.structs.get(source_id, {}).get(ref_id) - def resolve_struct(self, interface_id: int, struct_id: int) -> Optional["StructInfo"]: - """Look up a struct by interface_id and struct_id.""" - return self.structs.get(interface_id, {}).get(struct_id) + def resolve_enum(self, source_id: int, ref_id: int) -> Optional["EnumInfo"]: + """Look up an enum by source_id and ref_id. - def resolve_enum(self, interface_id: int, enum_id: int) -> Optional["EnumInfo"]: - """Look up an enum by interface_id and enum_id.""" - return self.enums.get(interface_id, {}).get(enum_id) + source_id=1: Global pool (1-based index) + source_id=2: Local interface enums + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_enum(ref_id) + return self.enums.get(source_id, {}).get(ref_id) def get_method(self, interface_id: int, method_id: int) -> Optional[MethodInfo]: """Find a method by interface_id and method_id.""" @@ -398,32 +492,110 @@ class EnumInfo: @dataclass class StructInfo: - """Struct definition from introspection.""" + """Struct definition from introspection. + + ``interface_id`` records which interface this struct was defined on, + enabling ``source_id=0`` (same-interface) resolution in the global pool. + + ``fields`` maps field names to ``ParameterType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (type 30=STRUCTURE, 32=ENUM). Call ``get_struct_string(registry)`` + to get human-readable names with struct/enum references resolved. + """ struct_id: int name: str - fields: Dict[str, int] # field_name -> type_id + fields: Dict[str, "ParameterType"] # field_name -> ParameterType + interface_id: Optional[int] = None # Interface this struct was defined on @property def field_type_names(self) -> Dict[str, str]: - """Get human-readable field type names.""" - return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} + """Get human-readable field type names using HamiltonDataType resolver.""" + return {name: _resolve_struct_field_type(pt) for name, pt in self.fields.items()} + + def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get struct definition as a readable string. - def get_struct_string(self) -> str: - """Get struct definition as a readable string.""" + If a TypeRegistry is provided, complex references (struct/enum fields) + are resolved to their names. + """ field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + f"{name}: {_resolve_struct_field_type(pt, registry)}" for name, pt in self.fields.items() ] fields_str = "\n ".join(field_strs) if field_strs else " (empty)" return f"struct {self.name} {{\n {fields_str}\n}}" -# GetStructs wire format (device sends 4 separate array fragments, not count+rows): +@dataclass +class GlobalTypePool: + """Flat, sequentially-indexed pool of structs/enums from global objects. + + Piglet builds this by walking ``robot.globals`` objects, iterating each + interface's structs/enums, and inserting them in encounter order. A + ``source_id=1`` reference uses ``ref_id`` as a **1-based** index into this + pool (piglet subtracts 1 for lookup). + """ + + structs: List[StructInfo] = field(default_factory=list) + enums: List[EnumInfo] = field(default_factory=list) + interface_structs: Dict[int, Dict[int, StructInfo]] = field(default_factory=dict) + + def resolve_struct(self, ref_id: int) -> Optional[StructInfo]: + """Look up global struct by 1-based ref_id.""" + idx = ref_id - 1 # 1-based → 0-based + return self.structs[idx] if 0 <= idx < len(self.structs) else None + + def resolve_struct_local(self, interface_id: int, ref_id: int) -> Optional[StructInfo]: + """Resolve a source_id=0 struct ref within a specific interface.""" + return self.interface_structs.get(interface_id, {}).get(ref_id) + + def resolve_enum(self, ref_id: int) -> Optional[EnumInfo]: + """Look up global enum by 1-based ref_id.""" + idx = ref_id - 1 + return self.enums[idx] if 0 <= idx < len(self.enums) else None + + def print_summary(self) -> None: + """Print global pool summary.""" + print(f"GlobalTypePool: {len(self.structs)} structs, {len(self.enums)} enums") + for i, s in enumerate(self.structs): + print(f" struct[{i+1}]: {s.name} ({len(s.fields)} fields)") + for i, e in enumerate(self.enums): + print(f" enum[{i+1}]: {e.name} ({len(e.values)} values)") + + +# GetStructs wire format (device sends 4 separate array fragments): # [0] STRING_ARRAY = struct names (one per struct) -# [1] U32_ARRAY = struct IDs -# [2] U8_ARRAY = field type IDs (flat across all structs) -# [3] STRING_ARRAY = field names (flat across all structs) -# Fields are split across structs by dividing evenly (e.g. 7 fields, 2 structs -> 3 + 4). +# [1] U32_ARRAY = numberStructureElements — field count for each struct +# [2] U8_ARRAY = structureElementTypes — flat field type bytes (variable width) +# [3] STRING_ARRAY = structureElementDescriptions — flat field names +# +# structureElementTypes byte encoding: +# - Simple types: 1 byte using HamiltonDataType values (40=F32, 23=BOOL, etc.) +# - Complex references: 3 bytes [sentinel, source_id, ref_id] +# sentinel=30 for STRUCTURE, sentinel=32 for ENUM (matches piglet) +# The HamiltonDataType namespace is used here, NOT the introspection type namespace. + + +def _resolve_struct_field_type( + pt: ParameterType, + registry: Optional["TypeRegistry"] = None, +) -> str: + """Resolve a struct field's ParameterType to a human-readable type name. + + Struct field type_ids use the HamiltonDataType wire namespace (e.g. 40=F32, + 23=BOOL) -- not the method-parameter introspection namespace. Complex + references (30=STRUCTURE, 32=ENUM) are resolved via the TypeRegistry when provided. + """ + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None: + if registry is not None: + s = registry.resolve_struct(pt.source_id, pt.ref_id) + if s: + return f"struct({s.name})" + e = registry.resolve_enum(pt.source_id, pt.ref_id) + if e: + return e.name + return f"ref(iface={pt.source_id}, id={pt.ref_id})" + return resolve_type_id(pt.type_id) # HamiltonDataType resolver # ============================================================================ @@ -446,8 +618,8 @@ def __init__(self, object_address: Address): class Response: name: Str version: Str - method_count: I32 - subobject_count: I32 + method_count: U32 + subobject_count: U16 class GetMethodCommand(HamiltonCommand): @@ -479,17 +651,22 @@ def parse_response_parameters(cls, data: bytes) -> dict: # The remaining fragments are STRING types containing type IDs as bytes. # Complex types (struct/enum refs) are 3-byte triples [type_id, source_id, ref_id]. # Labels are comma-separated, one per *logical* parameter (matching ParameterType count). - parameter_types_str = None parameter_labels_str = None if parser.has_remaining(): - _, parameter_types_str = parser.parse_next() + # Fragment 4: parameter_types. Wire type is STRING but payload is binary type IDs; + # use parse_next_raw() to avoid UTF-8 decode failure on bytes 0x80-0xFF. + _, flags, _, param_types_payload = parser.parse_next_raw() + if flags & PADDED_FLAG: + param_types_payload = param_types_payload[:-1] if param_types_payload else param_types_payload + param_types_payload = param_types_payload.rstrip(b"\x00") # STRING null terminator + all_types = _parse_type_ids(param_types_payload) + else: + all_types = [] if parser.has_remaining(): _, parameter_labels_str = parser.parse_next() - all_types = _parse_type_ids(parameter_types_str) if parameter_types_str else [] - all_labels: list[str] = [] if parameter_labels_str: all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] @@ -620,10 +797,18 @@ def build_parameters(self) -> HoiParams: @dataclass(frozen=True) class Response: - """GetStructs returns 4 fragments: struct names, struct IDs, field type IDs, field names.""" + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. + + Fragment layout (device signature: StructInfo): + [0] STRING_ARRAY = struct names (one per struct) + [1] U32_ARRAY = numberStructureElements: field count for each struct (NOT struct IDs) + [2] U8_ARRAY = structureElementTypes: flat field type IDs across all structs + [3] STRING_ARRAY = structureElementDescriptions: flat field names across all structs + Struct IDs are positional (0-indexed); the device does not send them explicitly. + """ struct_names: StrArray - struct_ids: U32Array + field_counts: U32Array field_type_ids: U8Array field_names: StrArray @@ -847,9 +1032,14 @@ async def get_structs_raw( async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: """Get struct definitions. - The device returns 4 fragments: struct_names (StrArray), struct_ids (U32Array), - field_type_ids (U8Array), field_names (StrArray). Fields are split across - structs in order (even split when not divisible). + The device returns 4 fragments per the StructInfo signature: + [0] struct_names (StrArray): one name per struct + [1] field_counts (U32Array): numberStructureElements — how many fields each struct has + [2] field_type_ids (U8Array): flat field type IDs across all structs + [3] field_names (StrArray): flat field names across all structs + + Struct IDs are positional (0-indexed); the device does not send them explicitly. + field_counts drives the field-to-struct assignment (no even-split heuristic). Args: address: Object address @@ -864,31 +1054,32 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI raise RuntimeError("GetStructsCommand returned None") struct_names = list(response.struct_names) - struct_ids = list(response.struct_ids) - field_type_ids = list(response.field_type_ids) + # field_counts = numberStructureElements from the device: logical fields per struct. + # Struct IDs are positional (0-indexed); the device does not send them. + field_counts = [int(c) for c in response.field_counts] + type_bytes = list(response.field_type_ids) # flat byte array; some entries are 3-byte triples field_names = list(response.field_names) - n_structs = len(struct_ids) - n_fields = len(field_names) + n_structs = len(field_counts) if n_structs == 0: return [] - # Split field names/type IDs across structs (even split) - counts = [ - n_fields // n_structs + (1 if i < n_fields % n_structs else 0) - for i in range(n_structs) - ] - offset = 0 + + # Walk type_bytes with a byte-level cursor (variable width: 1 byte for simple + # types, 3 bytes for 0xE8 complex references). field_counts gives the number + # of *logical* fields per struct, not the number of bytes to consume. + byte_offset = 0 # cursor into type_bytes + name_offset = 0 # cursor into field_names result: List[StructInfo] = [] - for i in range(n_structs): - cnt = counts[i] - name = struct_names[i] if i < len(struct_names) else f"Struct_{struct_ids[i]}" - # field_type_ids may have one extra (e.g. 8 for 7 names); use min to stay in range - types_slice = field_type_ids[offset : offset + cnt] - names_slice = field_names[offset : offset + cnt] - fields = dict(zip(names_slice, types_slice)) - result.append( - StructInfo(struct_id=int(struct_ids[i]), name=name, fields=fields) - ) - offset += cnt + for i, cnt in enumerate(field_counts): + name = struct_names[i] if i < len(struct_names) else f"Struct_{i}" + parsed = _parse_type_seq(type_bytes[byte_offset:], _COMPLEX_STRUCT_TYPE_IDS) + # Consume exactly `cnt` logical entries; advance byte_offset by the bytes used. + type_entries = parsed[:cnt] + bytes_used = sum(pt._byte_width for pt in type_entries) + names_slice = field_names[name_offset : name_offset + cnt] + fields = dict(zip(names_slice, type_entries)) + result.append(StructInfo(struct_id=i, name=name, fields=fields, interface_id=interface_id)) + byte_offset += bytes_used + name_offset += cnt return result async def get_all_methods(self, address: Address) -> List[MethodInfo]: @@ -921,7 +1112,11 @@ async def get_all_methods(self, address: Address) -> List[MethodInfo]: return methods - async def build_type_registry(self, address: Union[Address, str]) -> TypeRegistry: + async def build_type_registry( + self, + address: Union[Address, str], + global_pool: Optional[GlobalTypePool] = None, + ) -> TypeRegistry: """Build a complete TypeRegistry for an object. Uses InterfaceDescriptors (get_interfaces) as the canonical source of @@ -931,12 +1126,13 @@ async def build_type_registry(self, address: Union[Address, str]) -> TypeRegistr Args: address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. Returns: TypeRegistry with all type information for this object """ address = self._resolve_address(address) - registry = TypeRegistry(address=address) + registry = TypeRegistry(address=address, global_pool=global_pool) supported = await self.get_supported_interface0_method_ids(address) if GET_INTERFACES in supported: @@ -967,6 +1163,7 @@ async def build_type_registry_with_children( self, address: Union[Address, str], subobject_addresses: Optional[List[Address]] = None, + global_pool: Optional[GlobalTypePool] = None, ) -> TypeRegistry: """Build a TypeRegistry that includes structs/enums from child objects. @@ -979,12 +1176,13 @@ async def build_type_registry_with_children( address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). subobject_addresses: Optional list of child addresses to include. If None, all direct subobjects are discovered automatically. + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. Returns: TypeRegistry that can resolve types from both parent and children. """ address = self._resolve_address(address) - registry = await self.build_type_registry(address) + registry = await self.build_type_registry(address, global_pool=global_pool) if subobject_addresses is None: supported = await self.get_supported_interface0_method_ids(address) @@ -1012,6 +1210,49 @@ async def build_type_registry_with_children( return registry + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build the global type pool from global objects. + + This mirrors piglet's approach: walk each global object, iterate its + interfaces, and collect all structs/enums in sequential encounter order. + The resulting flat pool is used for source_id=1 lookups (1-based indexing). + + Args: + global_addresses: List of global object addresses + (from HamiltonTCPClient._global_object_addresses). + + Returns: + GlobalTypePool with all global structs and enums. + """ + pool = GlobalTypePool() + + for addr in global_addresses: + try: + supported = await self.get_supported_interface0_method_ids(addr) + if GET_INTERFACES not in supported: + continue + + interfaces = await self.get_interfaces(addr) + for iface in interfaces: + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.structs.extend(structs) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + pool.enums.extend(enums) + except Exception as e: + logger.debug("build_global_type_pool failed for %s: %s", addr, e) + + logger.info( + "Global type pool built: %d structs, %d enums from %d global objects", + len(pool.structs), len(pool.enums), len(global_addresses), + ) + return pool + async def get_method_by_id( self, address: Union[Address, str], @@ -1203,3 +1444,267 @@ async def diagnose_error( address, interface_id, method_id, registry=registry, hc_result=hc_result, ) + + +# ============================================================================ +# STRUCT / COMMAND VALIDATION +# ============================================================================ + + +def _snake_to_pascal(name: str) -> str: + """Convert snake_case to PascalCase for name comparison.""" + return "".join(word.capitalize() for word in name.split("_")) + + +def _get_wire_type_id(annotation) -> Optional[int]: + """Extract HamiltonDataType type_id from an Annotated type alias. + + Works for all our wire types: F32, PaddedBool, U32, WEnum, Str, + Annotated[X, Struct()], Annotated[list[X], StructArray()], etc. + + Returns None if the annotation doesn't carry a WireType. + """ + origin = getattr(annotation, "__class__", None) + # Handle typing.Annotated + metadata = getattr(annotation, "__metadata__", None) + if metadata: + for m in metadata: + if hasattr(m, "type_id"): + return m.type_id + return None + + +def _get_nested_dataclass(annotation): + """For Annotated[SomeDataclass, Struct()], return SomeDataclass. Else None.""" + import typing + args = getattr(annotation, "__args__", None) + if not args: + return None + base_type = args[0] + # For Annotated[list[X], StructArray()], dig into the list's inner type + inner_args = getattr(base_type, "__args__", None) + if inner_args: + base_type = inner_args[0] + import dataclasses + if dataclasses.is_dataclass(base_type): + return base_type + return None + + +@dataclass +class FieldMismatch: + """One field-level mismatch between hand-crafted and introspected definitions.""" + field_name: str + issue: str # e.g. "missing", "extra", "type mismatch", "order mismatch" + expected: str = "" + actual: str = "" + + def __str__(self): + s = f" {self.field_name}: {self.issue}" + if self.expected or self.actual: + s += f" (expected={self.expected}, actual={self.actual})" + return s + + +@dataclass +class ValidationResult: + """Result of comparing a hand-crafted dataclass against introspection.""" + name: str + passed: bool = False + mismatches: List[FieldMismatch] = field(default_factory=list) + children: List["ValidationResult"] = field(default_factory=list) + + def __str__(self): + icon = "✅" if self.passed else "❌" + lines = [f"{icon} {self.name}"] + for m in self.mismatches: + lines.append(str(m)) + for child in self.children: + for line in str(child).split("\n"): + lines.append(f" {line}") + return "\n".join(lines) + + +def validate_struct( + dataclass_cls, + introspected: StructInfo, + pool: Optional[GlobalTypePool] = None, +) -> ValidationResult: + """Compare a hand-crafted dataclass against an introspected StructInfo. + + Checks field count, field names (snake_case → PascalCase), field types + (extracts type_id from Annotated metadata), and field order. For nested + structs (Annotated[X, Struct()]), recursively validates the child struct + if a GlobalTypePool is provided. + + Args: + dataclass_cls: The hand-crafted dataclass class (not an instance). + introspected: The introspected StructInfo from the device. + pool: Optional GlobalTypePool for resolving nested struct refs. + + Returns: + ValidationResult with pass/fail and detailed mismatches. + """ + import dataclasses as dc + import typing + + result = ValidationResult(name=dataclass_cls.__name__) + mismatches = result.mismatches + + # Get hand-crafted fields + hints = typing.get_type_hints(dataclass_cls, include_extras=True) + hand_fields = list(dc.fields(dataclass_cls)) + hand_names = [f.name for f in hand_fields] + hand_pascal = [_snake_to_pascal(n) for n in hand_names] + + # Get introspected fields + intro_names = list(introspected.fields.keys()) + intro_types = list(introspected.fields.values()) + + # 1. Field count + if len(hand_names) != len(intro_names): + mismatches.append(FieldMismatch( + field_name="(count)", + issue="field count mismatch", + expected=str(len(intro_names)), + actual=str(len(hand_names)), + )) + + # 2. Field names (order-aware) + for i, (hp, ip) in enumerate(zip(hand_pascal, intro_names)): + if hp != ip: + mismatches.append(FieldMismatch( + field_name=hand_names[i], + issue=f"name mismatch at position {i}", + expected=ip, + actual=hp, + )) + + # 3. Extra / missing fields + hand_set = set(hand_pascal) + intro_set = set(intro_names) + for missing in intro_set - hand_set: + mismatches.append(FieldMismatch(field_name=missing, issue="missing in hand-crafted")) + for extra in hand_set - intro_set: + mismatches.append(FieldMismatch(field_name=extra, issue="extra in hand-crafted (not in introspection)")) + + # 4. Field types (where names match) + for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): + hp = _snake_to_pascal(hand_name) + if hp != intro_name: + continue # Already reported as name mismatch + annotation = hints.get(hand_name) + if annotation is None: + continue + hand_type_id = _get_wire_type_id(annotation) + intro_pt = intro_types[i] + if hand_type_id is not None and hand_type_id != intro_pt.type_id: + try: + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import HamiltonDataType + expected_name = HamiltonDataType(intro_pt.type_id).name + actual_name = HamiltonDataType(hand_type_id).name + except ValueError: + expected_name = str(intro_pt.type_id) + actual_name = str(hand_type_id) + mismatches.append(FieldMismatch( + field_name=hand_name, + issue="type mismatch", + expected=expected_name, + actual=actual_name, + )) + + # 5. Recursive validation for nested structs + if (pool is not None and intro_pt.is_complex + and intro_pt.source_id is not None and intro_pt.ref_id is not None + and intro_pt.type_id == 30): # STRUCTURE + nested_cls = _get_nested_dataclass(annotation) + if nested_cls: + if intro_pt.source_id == 1: + # Global pool ref (1-based index) + nested_struct = pool.resolve_struct(intro_pt.ref_id) + elif intro_pt.source_id == 0 and introspected.interface_id is not None: + # Same-interface ref: look up within that interface's struct group + nested_struct = pool.resolve_struct_local( + introspected.interface_id, intro_pt.ref_id + ) + else: + nested_struct = None + if nested_struct: + child_result = validate_struct(nested_cls, nested_struct, pool) + result.children.append(child_result) + + result.passed = len(mismatches) == 0 and all(c.passed for c in result.children) + return result + + +def validate_command( + command_cls, + registry: TypeRegistry, + pool: GlobalTypePool, + interface_id: int = 1, +) -> ValidationResult: + """Compare a PrepCommand against its introspected method signature. + + Matches the command's command_id to the introspected method_id on the given + interface. Validates that the command's struct parameters match the method's + expected struct types. + + Args: + command_cls: The PrepCommand subclass. + registry: TypeRegistry with the object's methods. + pool: GlobalTypePool for resolving struct refs. + interface_id: Interface ID to look up the method on (default 1 = Pipettor). + + Returns: + ValidationResult with pass/fail and details. + """ + import dataclasses as dc + import typing + + cmd_id = getattr(command_cls, "command_id", None) + result = ValidationResult(name=f"{command_cls.__name__} (cmd={cmd_id})") + + if cmd_id is None: + result.mismatches.append(FieldMismatch( + field_name="(class)", issue="no command_id attribute")) + result.passed = False + return result + + # Find matching introspected method + method = registry.get_method(interface_id, cmd_id) + if method is None: + result.mismatches.append(FieldMismatch( + field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]")) + result.passed = False + return result + + result.name = f"{command_cls.__name__} ↔ {method.name} [{interface_id}:{cmd_id}]" + + # Get command's payload fields (exclude 'dest' and class-level attrs) + hints = typing.get_type_hints(command_cls, include_extras=True) + payload_fields = [ + f for f in dc.fields(command_cls) if f.name != "dest" + ] + + # Match struct payload fields to introspected parameter types positionally + struct_fields = [ + (pf, hints.get(pf.name)) + for pf in payload_fields + if _get_nested_dataclass(hints.get(pf.name)) is not None + ] + struct_params = [ + pt for pt in method.parameter_types + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None + ] + + for (pf, annotation), pt in zip(struct_fields, struct_params): + intro_struct = pool.resolve_struct(pt.ref_id) + nested_cls = _get_nested_dataclass(annotation) + if intro_struct and nested_cls: + child_result = validate_struct(nested_cls, intro_struct, pool) + child_result.name = f"{pf.name} → {intro_struct.name} (ref={pt.ref_id})" + result.children.append(child_result) + + result.passed = all(c.passed for c in result.children) and len(result.mismatches) == 0 + return result + diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index bbfd8e22fb2..0e82f2aa1e5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -34,6 +34,9 @@ decode_fragment, ) + +PADDED_FLAG = 0x01 + # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol # ============================================================================ @@ -72,11 +75,16 @@ def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams Creates: [type_id:1][flags:1][length:2][data:n] + When flags & PADDED_FLAG, appends a trailing pad byte (Prep convention). + Callers pass unpadded data; _add_fragment centralizes pad handling. + Args: type_id: Data type ID - data: Fragment data bytes - flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + data: Fragment data bytes (unpadded; pad added here when flags set) + flags: Fragment flags (default: 0; PADDED_FLAG for BoolArray, PaddedBool, PaddedU8) """ + if flags & PADDED_FLAG: + data = data + b"\x00" fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() self._fragments.append(fragment) return self @@ -148,6 +156,7 @@ def parse_next(self) -> tuple[int, Any]: if self._offset + 4 > len(self._data): raise ValueError(f"Insufficient data at offset {self._offset}") type_id = self._data[self._offset] + flags = self._data[self._offset + 1] length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") payload_end = self._offset + 4 + length if payload_end > len(self._data): @@ -156,8 +165,31 @@ def parse_next(self) -> tuple[int, Any]: ) data = self._data[self._offset + 4 : payload_end] self._offset = payload_end + if (flags & PADDED_FLAG) and len(data) > 0: + data = data[:-1] return type_id, decode_fragment(type_id, data) + def parse_next_raw(self) -> tuple[int, int, int, bytes]: + """Return (type_id, flags, length, payload_bytes) without decoding. + + Use when the wire declares STRING (type_id=15) but the payload is binary + (e.g. GetMethod parameter_types). Normal parse_next() would UTF-8 decode + and fail on bytes like 0xaa. + """ + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload + def has_remaining(self) -> bool: return self._offset < len(self._data) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index a76762f83b6..677d146dc47 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -466,7 +466,7 @@ def test_bool_array(self): params = HoiParams().add([True, False, True], BoolArray).build() self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) self.assertEqual(params[1], 0x01) # flags = 0x01 for bool arrays - self.assertEqual(params[2:4], b"\x03\x00") # length = 3 + self.assertEqual(params[2:4], b"\x04\x00") # length = 4 (3 bools + pad) self.assertEqual(params[4:7], b"\x01\x00\x01") def test_string_array(self): diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py index 12544c751e7..060da3234e9 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py @@ -30,6 +30,7 @@ class HamiltonDataType(IntEnum): """Hamilton parameter data types for wire encoding in DataFragments.""" + VOID = 0 # Scalar integer types I8 = 1 I16 = 2 @@ -106,8 +107,6 @@ def __init__(self, type_id: int, fmt: str, padded: bool = False): def encode_into(self, value, params: HoiParams) -> HoiParams: data = _struct.pack(self.fmt, value) - if self.padded: - data += b"\x00" return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) def decode_from(self, data: bytes) -> Any: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 7ddad01e933..53028d479d1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -42,6 +42,7 @@ from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( GET_SUBOBJECT_ADDRESS, + GlobalTypePool, HamiltonIntrospection, ObjectInfo, TypeRegistry, @@ -305,6 +306,7 @@ def __init__( self._sequence_numbers: Dict[Address, int] = {} self._registry = ObjectRegistry(self) self._type_registries: Dict[Address, TypeRegistry] = {} + self._global_object_addresses: list[Address] = [] @property def interfaces(self) -> RegistryProxy: @@ -513,6 +515,9 @@ async def setup(self): # Step 4: Discover root objects await self._discover_root() + # Step 4b: Discover global objects (shared type definitions) + await self._discover_globals() + # Step 5: Walk depth-1 (or more) and register interfaces await self._discover_interfaces(max_depth=1) @@ -654,6 +659,52 @@ async def _discover_root(self): logger.info(f"✓ Discovery complete: {len(root_objects)} root objects") + async def _discover_globals(self): + """Discover global objects via Protocol 3 HARP_PROTOCOL_REQUEST. + + Global objects hold shared type definitions (structs/enums) referenced by + source_id=1 in method parameter triples. Piglet calls these "globals" and + uses request_id=2 (GLOBAL_OBJECT_ADDRESS) to discover them. + """ + logger.info("Discovering Hamilton global objects...") + + registration_service = Address(0, 0, 65534) + + global_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + global_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.GLOBAL_OBJECT_ADDRESS, + ) + + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + seq = self._allocate_sequence_number(registration_service) + packet = global_msg.build( + src=self.client_address, + req_addr=Address(0, 0, 0), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=True, + ) + + logger.info("[DISCOVER_GLOBALS] Sending global object discovery:") + logger.info(f"[DISCOVER_GLOBALS] Length: {len(packet)} bytes, Seq: {seq}") + logger.info(f"[DISCOVER_GLOBALS] Hex: {packet.hex(' ')}") + + await self.write(packet) + + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + + global_objects = self._parse_registration_response(response) + self._global_object_addresses = global_objects + logger.info(f"[DISCOVER_GLOBALS] ✓ Found {len(global_objects)} global objects") + async def _discover_interfaces(self, max_depth: int = 1) -> None: """Walk root and register objects up to max_depth. Default 1 = root + direct children.""" root_addresses = self._registry.get_root_addresses() @@ -873,6 +924,37 @@ async def send_command( assert last_error is not None raise last_error + async def introspect( + self, object_path: Optional[str] = None + ) -> tuple[GlobalTypePool, TypeRegistry]: + """Build introspection data on demand (for diagnostics/validation). + + Queries the device for global structs/enums and optionally builds a + TypeRegistry for a specific object. Does not cache — each call queries + the device fresh. + + Example:: + + pool, reg = await client.introspect("MLPrepRoot.PipettorRoot.Pipettor") + result = validate_struct(MyStruct, pool_struct, pool) + sig = await intro.resolve_signature(addr, 1, 9, reg) + + Args: + object_path: Optional dot-path to build a TypeRegistry for + (e.g. "MLPrepRoot.PipettorRoot.Pipettor"). If None, returns + an empty TypeRegistry with just the global pool attached. + + Returns: + (GlobalTypePool, TypeRegistry) tuple. + """ + intro = HamiltonIntrospection(self) + pool = await intro.build_global_type_pool(self._global_object_addresses) + if object_path: + reg = await intro.build_type_registry(object_path, global_pool=pool) + else: + reg = TypeRegistry(address=None, global_pool=pool) + return pool, reg + async def stop(self): """Close connection.""" try: From d74c9e083629b0095eb042e46bc916b0ee20bb13 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:53:20 -0800 Subject: [PATCH 15/42] Pull Interface resolution behavior from specific backends into shared HamiltonInterfaceResolver --- .../backends/hamilton/nimbus_backend.py | 129 +++++++----- .../backends/hamilton/nimbus_backend_tests.py | 15 +- .../backends/hamilton/prep_backend.py | 196 ++++++++---------- .../backends/hamilton/tcp/introspection.py | 98 +++++---- .../backends/hamilton/tcp_backend.py | 147 ++++++++----- 5 files changed, 340 insertions(+), 245 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 739ef763041..1e229fef806 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -28,7 +28,11 @@ U32Array, ) from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( + HamiltonTCPClient, + HamiltonInterfaceResolver, + InterfaceSpec, +) from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -436,10 +440,18 @@ class NimbusBackend(LiquidHandlerBackend): Uses HamiltonTCPClient (self.client) for TCP communication and introspection; implements LiquidHandlerBackend for liquid handling. - Interfaces: self.client.interfaces..address for NimbusCORE, Pipette. - Optional (e.g. DoorLock) via .is_available; DoorLock uses _has_door_lock. + Interfaces resolved lazily via _require() on first use. + + On-demand introspection: ``await self.client.introspect(path)``. """ + # Declare known object paths via InterfaceSpec. Optional interfaces (e.g. pipette, door_lock) may be absent on some systems. + _INTERFACES: dict[str, InterfaceSpec] = { + "nimbus_core": InterfaceSpec("NimbusCORE", True, True), + "pipette": InterfaceSpec("NimbusCORE.Pipette", False, True), + "door_lock": InterfaceSpec("NimbusCORE.DoorLock", False, True), + } + def __init__( self, host: str, @@ -464,12 +476,24 @@ def __init__( self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None self._channel_traversal_height: float = 146.0 # Default traversal height in mm - self._has_door_lock: bool = False # Set in setup() from .is_available (no Nimbus probe for enclosure) + self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) + + # --------------------------------------------------------------------------- + # Setup & interface resolution + # --------------------------------------------------------------------------- + + def _has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return self._resolver.has_interface(name) + + async def _require(self, name: str) -> Address: + """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" + return await self._resolver.require(name) async def setup(self, unlock_door: bool = False, force_initialize: bool = False): """Set up the Nimbus backend. - Interfaces: self.client.interfaces..address for required paths; optional via .is_available (e.g. _has_door_lock). + Interfaces: self.client.interfaces..address for required paths; optional via _has_interface(name). This method: 1. Establishes TCP connection and performs protocol initialization @@ -477,7 +501,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) 3. Queries channel configuration to get num_channels 4. Queries tip presence 5. Queries initialization status - 6. Locks door if available (when _has_door_lock) + 6. Locks door if available (when _has_interface(\"door_lock\")) 7. Conditionally initializes NimbusCore with InitializeSmartRoll (only if not initialized) 8. Optionally unlocks door after initialization @@ -495,14 +519,14 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{discovered}'. Wrong instrument?" ) from None - # Required objects are discovered; .address raises KeyError if missing - nimbus_core = self.client.interfaces.NimbusCORE.address - pipette = self.client.interfaces.NimbusCORE.Pipette.address - self._has_door_lock = self.client.interfaces.NimbusCORE.DoorLock.is_available + # Resolve all interfaces (required fail-fast; optional log and continue) + await self._resolver.run_setup_loop() - # Query channel configuration to get num_channels (use discovered address only) + nimbus_core = await self._require("nimbus_core") + + # Query channel configuration to get num_channels try: - config = await self.client.send_command(GetChannelConfiguration_1(nimbus_core)) + config = await self.client.send_command(GetChannelConfiguration_1(dest=nimbus_core)) assert config is not None, "GetChannelConfiguration_1 command returned None" self._num_channels = config.channels logger.info(f"Channel configuration: {config.channels} channels") @@ -517,9 +541,9 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) except Exception as e: logger.warning(f"Failed to query tip presence: {e}") - # Query initialization status (use discovered address only) + # Query initialization status try: - init_status = await self.client.send_command(IsInitialized(nimbus_core)) + init_status = await self.client.send_command(IsInitialized(dest=nimbus_core)) assert init_status is not None, "IsInitialized command returned None" self._is_initialized = bool(init_status.value) logger.info(f"Instrument initialized: {self._is_initialized}") @@ -529,7 +553,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Lock door if available (optional - no error if not found) # This happens before initialization - if self._has_door_lock: + if self._has_interface("door_lock"): try: if not await self.is_door_locked(): await self.lock_door() @@ -543,23 +567,26 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Conditional initialization - only if not already initialized if not self._is_initialized or force_initialize: - # Set channel configuration for each channel (required before InitializeSmartRoll) - try: - # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel - # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] - for channel in range(1, self.num_channels + 1): - await self.client.send_command( - SetChannelConfiguration( - dest=pipette, - channel=channel, - indexes=[1, 3, 4], - enables=[True, False, False, False], + # Set channel configuration for each channel (when Pipette is present; required before InitializeSmartRoll) + if self._has_interface("pipette"): + try: + # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel + # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] + for channel in range(1, self.num_channels + 1): + await self.client.send_command( + SetChannelConfiguration( + dest=await self._require("pipette"), + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) ) - ) - logger.info(f"Channel configuration set for {self.num_channels} channels") - except Exception as e: - logger.error(f"Failed to set channel configuration: {e}") - raise + logger.info(f"Channel configuration set for {self.num_channels} channels") + except Exception as e: + logger.error(f"Failed to set channel configuration: {e}") + raise + else: + logger.info("Skipping channel configuration (no Pipette interface)") # Initialize NimbusCore with InitializeSmartRoll using waste positions try: @@ -601,7 +628,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) logger.info("Instrument already initialized, skipping initialization") # Unlock door if requested (optional - no error if not found) - if unlock_door and self._has_door_lock: + if unlock_door and self._has_interface("door_lock"): try: await self.unlock_door() except RuntimeError: @@ -652,7 +679,7 @@ async def park(self): RuntimeError: If NimbusCORE address was not discovered during setup. """ try: - await self.client.send_command(Park(self.client.interfaces.NimbusCORE.address)) + await self.client.send_command(Park(await self._require("nimbus_core"))) logger.info("Instrument parked successfully") except Exception as e: logger.error(f"Failed to park instrument: {e}") @@ -664,11 +691,11 @@ async def is_door_locked(self) -> bool: Returns: True if door is locked, False if unlocked or if door lock is not available. """ - if not self._has_door_lock: + if not self._has_interface("door_lock"): return False try: - status = await self.client.send_command(IsDoorLocked(self.client.interfaces.NimbusCORE.DoorLock.address)) + status = await self.client.send_command(IsDoorLocked(await self._require("door_lock"))) assert status is not None, "IsDoorLocked command returned None" return bool(status.locked) except Exception as e: @@ -677,11 +704,11 @@ async def is_door_locked(self) -> bool: async def lock_door(self) -> None: """Lock the door. No-op if door lock is not available.""" - if not self._has_door_lock: + if not self._has_interface("door_lock"): return try: - await self.client.send_command(LockDoor(self.client.interfaces.NimbusCORE.DoorLock.address)) + await self.client.send_command(LockDoor(await self._require("door_lock"))) logger.info("Door locked successfully") except Exception as e: logger.error(f"Failed to lock door: {e}") @@ -689,11 +716,11 @@ async def lock_door(self) -> None: async def unlock_door(self) -> None: """Unlock the door. No-op if door lock is not available.""" - if not self._has_door_lock: + if not self._has_interface("door_lock"): return try: - await self.client.send_command(UnlockDoor(self.client.interfaces.NimbusCORE.DoorLock.address)) + await self.client.send_command(UnlockDoor(await self._require("door_lock"))) logger.info("Door unlocked successfully") except Exception as e: logger.error(f"Failed to unlock door: {e}") @@ -714,7 +741,7 @@ async def request_tip_presence(self) -> List[Optional[bool]]: A list of length `num_channels` where each element is `True` if a tip is mounted, `False` if not, or `None` if unknown. """ - tip_status = await self.client.send_command(IsTipPresent(self.client.interfaces.NimbusCORE.Pipette.address)) + tip_status = await self.client.send_command(IsTipPresent(await self._require("pipette"))) assert tip_status is not None, "IsTipPresent command returned None" return [bool(v) for v in tip_status.tip_present] @@ -967,7 +994,7 @@ async def pick_up_tips( # Create and send command command = PickupTips( - dest=self.client.interfaces.NimbusCORE.Pipette.address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1064,7 +1091,7 @@ async def drop_tips( ) command = DropTipsRoll( - dest=self.client.interfaces.NimbusCORE.Pipette.address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1097,7 +1124,7 @@ async def drop_tips( ) command = DropTips( - dest=self.client.interfaces.NimbusCORE.Pipette.address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1179,10 +1206,10 @@ async def aspirate( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.client.send_command(EnableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) + await self.client.send_command(EnableADC(await self._require("pipette"), channels_involved)) logger.info("Enabled ADC before aspirate") else: - await self.client.send_command(DisableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) + await self.client.send_command(DisableADC(await self._require("pipette"), channels_involved)) logger.info("Disabled ADC before aspirate") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -1193,7 +1220,7 @@ async def aspirate( try: config = await self.client.send_command( GetChannelConfiguration( - self.client.interfaces.NimbusCORE.Pipette.address, + await self._require("pipette"), channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) @@ -1368,7 +1395,7 @@ async def aspirate( # Create and send Aspirate command command = Aspirate( - dest=self.client.interfaces.NimbusCORE.Pipette.address, + dest=await self._require("pipette"), aspirate_type=aspirate_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -1475,10 +1502,10 @@ async def dispense( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.client.send_command(EnableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) + await self.client.send_command(EnableADC(await self._require("pipette"), channels_involved)) logger.info("Enabled ADC before dispense") else: - await self.client.send_command(DisableADC(self.client.interfaces.NimbusCORE.Pipette.address, channels_involved)) + await self.client.send_command(DisableADC(await self._require("pipette"), channels_involved)) logger.info("Disabled ADC before dispense") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -1489,7 +1516,7 @@ async def dispense( try: config = await self.client.send_command( GetChannelConfiguration( - self.client.interfaces.NimbusCORE.Pipette.address, + await self._require("pipette"), channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) @@ -1667,7 +1694,7 @@ async def dispense( # Create and send Dispense command command = Dispense( - dest=self.client.interfaces.NimbusCORE.Pipette.address, + dest=await self._require("pipette"), dispense_type=dispense_type, channels_involved=channels_involved, x_positions=x_positions_full, diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index c55ae3e7960..decf1e874c8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -662,7 +662,10 @@ def _setup_backend() -> NimbusBackend: "NimbusCORE.DoorLock", ObjectInfo("DoorLock", "1.0", 3, 0, Address(1, 1, 268)), ) - backend._has_door_lock = True # DoorLock is present in registry + # Pre-populate resolver cache so _has_interface and _require use cached addresses + backend._resolver._resolved["nimbus_core"] = Address(1, 1, 48896) + backend._resolver._resolved["pipette"] = Address(1, 1, 257) + backend._resolver._resolved["door_lock"] = Address(1, 1, 268) backend._is_initialized = True return backend @@ -710,8 +713,8 @@ async def test_park(self): self.assertIsInstance(self._get_command(Park), Park) async def test_door_methods_without_address_raise(self): - # When door lock is not available (_has_door_lock=False), methods return early without sending. - self.backend._has_door_lock = False + # When door lock is not available (_has_interface("door_lock")=False), methods return early without sending. + self.backend._resolver._resolved["door_lock"] = None self.mock_send.reset_mock() await self.backend.lock_door() @@ -723,13 +726,15 @@ async def test_door_methods_without_address_raise(self): self.assertEqual(self.mock_send.call_count, 0) async def test_park_without_address_raises(self): - # Backend with no registry entries (e.g. setup() not run); .address raises KeyError + # Backend with no resolved interfaces (e.g. setup() not run); _require raises RuntimeError backend = NimbusBackend(host="192.168.1.100", port=2000) backend._num_channels = 8 backend._is_initialized = True - with self.assertRaises(KeyError): + with self.assertRaises(RuntimeError) as ctx: await backend.park() + self.assertIn("Could not find interface", str(ctx.exception)) + self.assertIn("nimbus_core", str(ctx.exception)) class TestNimbusBackendSerialization(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index e96228e64f1..26e1870b7f6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -47,7 +47,11 @@ ) from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( + HamiltonTCPClient, + HamiltonInterfaceResolver, + InterfaceSpec, +) from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -1589,7 +1593,7 @@ class _WasteSiteDefinitionWire: # ----------------------------------------------------------------------------- -# Config queries (MLPrep / DeckConfiguration) for _probe_hardware_config +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config # ----------------------------------------------------------------------------- @@ -1716,9 +1720,20 @@ class PrepBackend(LiquidHandlerBackend): Uses HamiltonTCPClient (self.client) for communication and introspection; implements LiquidHandlerBackend for liquid handling. - Interfaces: self.client.interfaces..address for MLPrep, Pipettor, MPH. + Interfaces resolved lazily via _require() on first use. + + On-demand introspection: ``await self.client.introspect(path)``. """ + # Declare known object paths via InterfaceSpec. deck_config required (key positions, traverse height, deck info). + _INTERFACES: dict[str, InterfaceSpec] = { + "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), + "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), + "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), + "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), + "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), + } + def __init__( self, host: str, @@ -1740,6 +1755,11 @@ def __init__( ) self._config: Optional[InstrumentConfig] = None self._user_traverse_height: Optional[float] = default_traverse_height + self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) + + def _has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return self._resolver.has_interface(name) def set_default_traverse_height(self, value: float) -> None: """Set the default traverse height (mm) used when final_z is not passed to pick_up_tips/drop_tips. @@ -1750,9 +1770,13 @@ def set_default_traverse_height(self, value: float) -> None: self._user_traverse_height = value # --------------------------------------------------------------------------- - # Setup & discovery + # Setup & interface resolution # --------------------------------------------------------------------------- + async def _require(self, name: str) -> Address: + """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" + return await self._resolver.require(name) + async def setup(self, smart: bool = True, force_initialize: bool = False): """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. @@ -1779,18 +1803,8 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{discovered}'. Wrong instrument?" ) from None - await self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.resolve() - - try: - await self.client.interfaces.MLPrepRoot.MphRoot.MPH.resolve() - logger.info("MPH head discovered at %s", self.client.interfaces.MLPrepRoot.MphRoot.MPH.address) - except Exception as e: - logger.info("MPH head not available (instrument may not have MPH): %s", e) - - try: - self.client.interfaces.MLPrepRoot.MLPrep.address - except KeyError: - raise RuntimeError("MLPrep object not discovered. Cannot proceed with setup.") from None + # Resolve all interfaces (required fail-fast; optional log and continue) + await self._resolver.run_setup_loop() if force_initialize: await self._run_initialize(smart=smart) @@ -1807,7 +1821,7 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): await self._run_initialize(smart=smart) logger.info("Prep initialization complete") - self._config = await self._probe_hardware_config() + self._config = await self._get_hardware_config() logger.info( "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " "deck_bounds=%s, deck_sites=%d, waste_sites=%d", @@ -1826,7 +1840,7 @@ async def _run_initialize(self, smart: bool): """Send PrepInitialize to MLPrep (shared by setup).""" await self.client.send_command( PrepInitialize( - dest=self.client.interfaces.MLPrepRoot.MLPrep.address, + dest=await self._require("mlprep"), smart=smart, tip_drop_params=InitTipDropParameters( default_values=True, @@ -1837,9 +1851,9 @@ async def _run_initialize(self, smart: bool): ) ) - async def _probe_hardware_config(self) -> InstrumentConfig: - """Query MLPrep and DeckConfiguration for hardware config, deck sites, and waste sites.""" - mlprep = self.client.interfaces.MLPrepRoot.MLPrep.address + async def _get_hardware_config(self) -> InstrumentConfig: + """Aggregate getters: query MLPrep and DeckConfiguration for hardware config, deck sites, and waste sites.""" + mlprep = await self._require("mlprep") enc_resp = await self.client.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) safe_resp = await self.client.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) height_resp = await self.client.send_command(PrepGetDefaultTraverseHeight(dest=mlprep)) @@ -1850,53 +1864,48 @@ async def _probe_hardware_config(self) -> InstrumentConfig: deck_bounds: Optional[DeckBounds] = None deck_sites: Tuple[DeckSiteInfo, ...] = () waste_sites: Tuple[WasteSiteInfo, ...] = () - try: - await self.client.interfaces.MLPrepRoot.MLPrepCalibration.DeckConfiguration.resolve() - deck_addr = self.client.interfaces.MLPrepRoot.MLPrepCalibration.DeckConfiguration.address - - bounds_resp = await self.client.send_command(PrepGetDeckBounds(dest=deck_addr)) - if bounds_resp: - deck_bounds = DeckBounds( - min_x=bounds_resp.min_x, - max_x=bounds_resp.max_x, - min_y=bounds_resp.min_y, - max_y=bounds_resp.max_y, - min_z=bounds_resp.min_z, - max_z=bounds_resp.max_z, - ) + deck_addr = await self._require("deck_config") + + bounds_resp = await self.client.send_command(PrepGetDeckBounds(dest=deck_addr)) + if bounds_resp: + deck_bounds = DeckBounds( + min_x=bounds_resp.min_x, + max_x=bounds_resp.max_x, + min_y=bounds_resp.min_y, + max_y=bounds_resp.max_y, + min_z=bounds_resp.min_z, + max_z=bounds_resp.max_z, + ) - sites_resp = await self.client.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) - if sites_resp and sites_resp.sites: - deck_sites = tuple( - DeckSiteInfo( - id=int(s.id), - left_bottom_front_x=float(s.left_bottom_front_x), - left_bottom_front_y=float(s.left_bottom_front_y), - left_bottom_front_z=float(s.left_bottom_front_z), - length=float(s.length), - width=float(s.width), - height=float(s.height), - ) - for s in sites_resp.sites + sites_resp = await self.client.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) + if sites_resp and sites_resp.sites: + deck_sites = tuple( + DeckSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), ) - logger.info("Discovered %d deck sites", len(deck_sites)) - - waste_resp = await self.client.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) - if waste_resp and waste_resp.sites: - waste_sites = tuple( - WasteSiteInfo( - index=int(s.index), - x_position=float(s.x_position), - y_position=float(s.y_position), - z_position=float(s.z_position), - z_seek=float(s.z_seek), - ) - for s in waste_resp.sites + for s in sites_resp.sites + ) + logger.info("Discovered %d deck sites", len(deck_sites)) + + waste_resp = await self.client.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) + if waste_resp and waste_resp.sites: + waste_sites = tuple( + WasteSiteInfo( + index=int(s.index), + x_position=float(s.x_position), + y_position=float(s.y_position), + z_position=float(s.z_position), + z_seek=float(s.z_seek), ) - logger.info("Discovered %d waste sites: %s", len(waste_sites), waste_sites) - - except (KeyError, RuntimeError) as e: - logger.debug("DeckConfiguration not available: %s", e) + for s in waste_resp.sites + ) + logger.info("Discovered %d waste sites: %s", len(waste_sites), waste_sites) return InstrumentConfig( deck_bounds=deck_bounds, @@ -1911,27 +1920,6 @@ async def _probe_hardware_config(self) -> InstrumentConfig: # Properties # --------------------------------------------------------------------------- - @property - def mlprep_address(self) -> Optional[Address]: - try: - return self.client.interfaces.MLPrepRoot.MLPrep.address - except KeyError: - return None - - @property - def pipettor_address(self) -> Optional[Address]: - try: - return self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address - except KeyError: - return None - - @property - def coordinator_address(self) -> Optional[Address]: - try: - return self.client.interfaces.MLPrepRoot.ChannelCoordinator.address - except KeyError: - return None - @property def num_channels(self) -> int: """Prep has 2 channels (front and rear).""" @@ -1970,7 +1958,7 @@ async def is_initialized(self) -> bool: Requires MLPrep to be discovered (e.g. after self.client.setup() and _discover_prep_objects()). Call before or after PrepInitialize to test. """ - result = await self.client.send_command(PrepGetIsInitialized(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + result = await self.client.send_command(PrepGetIsInitialized(dest=await self._require("mlprep"))) if result is None: return False return bool(result.value) @@ -1978,7 +1966,7 @@ async def is_initialized(self) -> bool: async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: """Return tip/needle definitions registered on the instrument (GetTipAndNeedleDefinitions, cmd=11).""" result = await self.client.send_command( - PrepGetTipAndNeedleDefinitions(dest=self.client.interfaces.MLPrepRoot.MLPrep.address) + PrepGetTipAndNeedleDefinitions(dest=await self._require("mlprep")) ) if result is None or not getattr(result, "definitions", None): return () @@ -1986,14 +1974,14 @@ async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: async def is_parked(self) -> bool: """Query whether MLPrep is parked (IsParked, cmd=34).""" - result = await self.client.send_command(PrepIsParked(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + result = await self.client.send_command(PrepIsParked(dest=await self._require("mlprep"))) if result is None: return False return bool(result.value) async def is_spread(self) -> bool: """Query whether channels are spread (IsSpread, cmd=35). Pipettor commands typically require spread state.""" - result = await self.client.send_command(PrepIsSpread(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + result = await self.client.send_command(PrepIsSpread(dest=await self._require("mlprep"))) if result is None: return False return bool(result.value) @@ -2063,7 +2051,7 @@ async def pick_up_tips( await self.client.send_command( PrepPickUpTips( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), tip_positions=tip_positions, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2123,7 +2111,7 @@ async def drop_tips( await self.client.send_command( PrepDropTips( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), tip_positions=tip_positions, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2199,7 +2187,7 @@ async def pick_up_tips_mph( await self.client.send_command( MphPickupTips( - dest=self.client.interfaces.MLPrepRoot.MphRoot.MPH.address, + dest=await self._require("mph"), tip_parameters=tip_parameters, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2257,7 +2245,7 @@ async def drop_tips_mph( await self.client.send_command( MphDropTips( - dest=self.client.interfaces.MLPrepRoot.MphRoot.MPH.address, + dest=await self._require("mph"), drop_parameters=drop_parameters, final_z=resolved_final_z, seek_speed=seek_speed, @@ -2324,7 +2312,7 @@ async def aspirate( await self.client.send_command( PrepAspirateNoLldMonitoring( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), aspirate_parameters=aspirate_parameters, ) ) @@ -2388,7 +2376,7 @@ async def dispense( await self.client.send_command( PrepDispenseNoLld( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), dispense_parameters=dispense_parameters, ) ) @@ -2427,28 +2415,28 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: async def park(self) -> None: """Park the instrument.""" - await self.client.send_command(PrepPark(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + await self.client.send_command(PrepPark(dest=await self._require("mlprep"))) async def spread(self) -> None: """Spread channels.""" - await self.client.send_command(PrepSpread(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + await self.client.send_command(PrepSpread(dest=await self._require("mlprep"))) async def method_begin(self, automatic_pause: bool = False) -> None: """Signal the start of a liquid-handling method.""" await self.client.send_command( PrepMethodBegin( - dest=self.client.interfaces.MLPrepRoot.MLPrep.address, + dest=await self._require("mlprep"), automatic_pause=automatic_pause, ) ) async def method_end(self) -> None: """Signal the end of a liquid-handling method.""" - await self.client.send_command(PrepMethodEnd(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + await self.client.send_command(PrepMethodEnd(dest=await self._require("mlprep"))) async def method_abort(self) -> None: """Abort the current method.""" - await self.client.send_command(PrepMethodAbort(dest=self.client.interfaces.MLPrepRoot.MLPrep.address)) + await self.client.send_command(PrepMethodAbort(dest=await self._require("mlprep"))) async def set_deck_light( self, white: int, red: int, green: int, blue: int @@ -2456,7 +2444,7 @@ async def set_deck_light( """Set the deck LED colour.""" await self.client.send_command( PrepSetDeckLight( - dest=self.client.interfaces.MLPrepRoot.MLPrep.address, + dest=await self._require("mlprep"), white=white, red=red, green=green, @@ -2476,7 +2464,7 @@ async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> No ) await self.client.send_command( PrepMoveToPosition( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), move_parameters=move_parameters, ) ) @@ -2489,7 +2477,7 @@ async def move_to_position_via_lane(self, move_parameters: GantryMoveXYZParamete ) await self.client.send_command( PrepMoveToPositionViaLane( - dest=self.client.interfaces.MLPrepRoot.PipettorRoot.Pipettor.address, + dest=await self._require("pipettor"), move_parameters=move_parameters, ) ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index feec86f3c88..6fd956bc292 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -146,9 +146,10 @@ def resolve_type_id(type_id: int) -> str: } # Type ID sets for categorization -_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 82, 102} +# 78 = enum (Argument); 60, 64 = struct (ReturnValue) — see _INTROSPECTION_TYPE_NAMES comments +_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 78, 82, 102} _RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} -_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} +_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 60, 64, 69, 81, 85, 104, 105} # Complex type sentinels: byte values that begin a 3-byte triple [type_id, source_id, ref_id]. # The two contexts (method parameterTypes vs struct structureElementTypes) use different sentinels. @@ -261,10 +262,10 @@ def resolve_name(self, registry: Optional["TypeRegistry"] = None) -> str: return base if registry is None: return f"{base}(iface={self.source_id}, id={self.ref_id})" - if "struct" in base.lower(): + if self.is_struct_ref: s = registry.resolve_struct(self.source_id, self.ref_id) return s.name if s else f"{base}(iface={self.source_id}, id={self.ref_id})" - if "enum" in base.lower(): + if self.is_enum_ref: e = registry.resolve_enum(self.source_id, self.ref_id) return e.name if e else f"{base}(iface={self.source_id}, id={self.ref_id})" return f"{base}(iface={self.source_id}, id={self.ref_id})" @@ -433,8 +434,10 @@ def resolve_struct(self, source_id: int, ref_id: int) -> Optional["StructInfo"]: """ if source_id == 1 and self.global_pool is not None: return self.global_pool.resolve_struct(ref_id) - # source_id=2 or fallback: treat source_id as interface_id - return self.structs.get(source_id, {}).get(ref_id) + if source_id == 2: + return self.structs.get(source_id, {}).get(ref_id) + logger.warning("resolve_struct: unhandled source_id=%d ref_id=%d", source_id, ref_id) + return None def resolve_enum(self, source_id: int, ref_id: int) -> Optional["EnumInfo"]: """Look up an enum by source_id and ref_id. @@ -588,12 +591,14 @@ def _resolve_struct_field_type( """ if pt.is_complex and pt.source_id is not None and pt.ref_id is not None: if registry is not None: - s = registry.resolve_struct(pt.source_id, pt.ref_id) - if s: - return f"struct({s.name})" - e = registry.resolve_enum(pt.source_id, pt.ref_id) - if e: - return e.name + if pt.is_struct_ref: + s = registry.resolve_struct(pt.source_id, pt.ref_id) + if s: + return f"struct({s.name})" + elif pt.is_enum_ref: + e = registry.resolve_enum(pt.source_id, pt.ref_id) + if e: + return e.name return f"ref(iface={pt.source_id}, id={pt.ref_id})" return resolve_type_id(pt.type_id) # HamiltonDataType resolver @@ -689,6 +694,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: if label: return_labels.append(label) else: + logger.warning("Unknown introspection type category for type_id=%d; treating as parameter", pt.type_id) parameter_types.append(pt) if label: parameter_labels.append(label) @@ -1258,26 +1264,27 @@ async def get_method_by_id( address: Union[Address, str], interface_id: int, method_id: int, + registry: Optional[TypeRegistry] = None, ) -> Optional[MethodInfo]: """Return the method with the given interface_id and method_id (action id). - Use this when you get a COMMAND_EXCEPTION to see the expected parameter - names and types for the command that was rejected. Example:: - - intro = HamiltonIntrospection(backend.client) - method = await intro.get_method_by_id(mph_address, interface_id=1, method_id=9) - if method: - print("Expected parameters:", method.parameter_labels) - print("Signature:", method.get_signature_string()) + When a TypeRegistry is provided and contains the method, returns it + without any device round-trips. Falls back to a full device scan only + when no registry is available or the method isn't in it. Args: address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). interface_id: Interface ID (e.g. 1 for IChannel/IMph). method_id: Method/command ID (e.g. 9 for PickupTips). + registry: Optional TypeRegistry with cached methods. Returns: MethodInfo for the matching method, or None if not found. """ + if registry is not None: + cached = registry.get_method(interface_id, method_id) + if cached is not None: + return cached address = self._resolve_address(address) methods = await self.get_all_methods(address) for m in methods: @@ -1451,9 +1458,13 @@ async def diagnose_error( # ============================================================================ -def _snake_to_pascal(name: str) -> str: - """Convert snake_case to PascalCase for name comparison.""" - return "".join(word.capitalize() for word in name.split("_")) +def _normalize_name(name: str) -> str: + """Normalize a name for comparison (remove underscores, make lowercase). + + Allows Pythonic `snake_case` (e.g. `z_liquid_exit_speed`) to match + Hamilton's arbitrary PascalCase (`ZLiquidExitSpeed` or `Zliquidexitspeed`). + """ + return name.replace("_", "").lower() def _get_wire_type_id(annotation) -> Optional[int]: @@ -1555,10 +1566,11 @@ def validate_struct( hints = typing.get_type_hints(dataclass_cls, include_extras=True) hand_fields = list(dc.fields(dataclass_cls)) hand_names = [f.name for f in hand_fields] - hand_pascal = [_snake_to_pascal(n) for n in hand_names] + hand_norm = [_normalize_name(n) for n in hand_names] # Get introspected fields intro_names = list(introspected.fields.keys()) + intro_norm = [_normalize_name(n) for n in intro_names] intro_types = list(introspected.fields.values()) # 1. Field count @@ -1571,27 +1583,33 @@ def validate_struct( )) # 2. Field names (order-aware) - for i, (hp, ip) in enumerate(zip(hand_pascal, intro_names)): - if hp != ip: + for i, (hn_norm, in_norm) in enumerate(zip(hand_norm, intro_norm)): + if hn_norm != in_norm: mismatches.append(FieldMismatch( field_name=hand_names[i], issue=f"name mismatch at position {i}", - expected=ip, - actual=hp, + expected=intro_names[i], + actual=hand_names[i], )) # 3. Extra / missing fields - hand_set = set(hand_pascal) - intro_set = set(intro_names) - for missing in intro_set - hand_set: - mismatches.append(FieldMismatch(field_name=missing, issue="missing in hand-crafted")) - for extra in hand_set - intro_set: - mismatches.append(FieldMismatch(field_name=extra, issue="extra in hand-crafted (not in introspection)")) + hand_set = set(hand_norm) + intro_set = set(intro_norm) + + # For error reporting, we want the original casing, so we build reverse maps + hand_map = {hn_norm: h for hn_norm, h in zip(hand_norm, hand_names)} + intro_map = {in_norm: i for in_norm, i in zip(intro_norm, intro_names)} + + for missing_norm in intro_set - hand_set: + original_intro = intro_map[missing_norm] + mismatches.append(FieldMismatch(field_name=original_intro, issue="missing in hand-crafted")) + for extra_norm in hand_set - intro_set: + original_hand = hand_map[extra_norm] + mismatches.append(FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)")) # 4. Field types (where names match) for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): - hp = _snake_to_pascal(hand_name) - if hp != intro_name: + if _normalize_name(hand_name) != _normalize_name(intro_name): continue # Already reported as name mismatch annotation = hints.get(hand_name) if annotation is None: @@ -1698,7 +1716,13 @@ def validate_command( ] for (pf, annotation), pt in zip(struct_fields, struct_params): - intro_struct = pool.resolve_struct(pt.ref_id) + if pt.source_id == 1: + intro_struct = pool.resolve_struct(pt.ref_id) + elif pt.source_id == 0: + # Same-interface ref: would need interface_id context; skip for now + intro_struct = None + else: + intro_struct = pool.resolve_struct(pt.ref_id) nested_cls = _get_nested_dataclass(annotation) if intro_struct and nested_cls: child_result = validate_struct(nested_cls, intro_struct, pool) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 53028d479d1..de8ff87d6ca 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -239,6 +239,98 @@ def __repr__(self) -> str: return f"" +@dataclass +class InterfaceSpec: + """Spec for a backend interface: instrument path, required flag, and raise-when-missing behavior. + + Logs use the dict key (name) and path only; no display_name. + """ + + path: str + required: bool + raise_when_missing: bool = True + + +class HamiltonInterfaceResolver: + """Resolves named interfaces (path -> Address) with caching and required/optional behavior. + + Used by Nimbus and Prep backends. Holds client, interfaces dict, and _resolved cache. + """ + + def __init__(self, client: "HamiltonTCPClient", interfaces: dict[str, InterfaceSpec]): + self.client = client + self.interfaces = interfaces + self._resolved: dict[str, Optional[Address]] = {} + + def clear(self) -> None: + """Clear cached addresses (for reconnect-safe setup).""" + self._resolved.clear() + + def has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return name in self._resolved and self._resolved[name] is not None + + async def get(self, name: str) -> Optional[Address]: + """Resolve once and cache. Required + missing -> raise. Optional + missing -> cache None, return None.""" + if name not in self.interfaces: + raise KeyError(f"Unknown interface: {name}") + spec = self.interfaces[name] + if name in self._resolved: + return self._resolved[name] + try: + await self.client.interfaces[spec.path].resolve() + addr = self.client.interfaces[spec.path].address + self._resolved[name] = addr + logger.debug("Resolved %s → %s (%s)", name, addr, spec.path) + return addr + except KeyError: + if spec.required: + msg = f"Could not find interface '{name}' ({spec.path}) on instrument." + raise RuntimeError(msg) from None + self._resolved[name] = None + return None + + async def require(self, name: str) -> Address: + """Return address or raise. If optional and missing: log warning when raise_when_missing, then raise.""" + if name not in self.interfaces: + raise KeyError(f"Unknown interface: {name}") + spec = self.interfaces[name] + msg = f"Could not find interface '{name}' ({spec.path}) on instrument." + if name in self._resolved: + if self._resolved[name] is None: + if spec.raise_when_missing: + logger.warning("%s", msg) + raise RuntimeError(msg) from None + return self._resolved[name] + try: + await self.client.interfaces[spec.path].resolve() + addr = self.client.interfaces[spec.path].address + self._resolved[name] = addr + logger.debug("Resolved %s → %s (%s)", name, addr, spec.path) + return addr + except KeyError: + if spec.required: + raise RuntimeError(msg) from None + self._resolved[name] = None + if spec.raise_when_missing: + logger.warning("%s", msg) + raise RuntimeError(msg) from None + + async def run_setup_loop(self) -> None: + """Clear cache, then resolve all interfaces: required fail-fast; optional log and continue.""" + self.clear() + for name, spec in self.interfaces.items(): + if spec.required: + addr = await self.require(name) + logger.info("Found interface '%s' (%s) at %s", name, spec.path, addr) + else: + addr = await self.get(name) + if addr is not None: + logger.info("Found interface '%s' (%s) at %s", name, spec.path, addr) + else: + logger.info("Could not find interface '%s' (%s) on instrument.", name, spec.path) + + @dataclass class HamiltonError: """Hamilton error response.""" @@ -518,8 +610,13 @@ async def setup(self): # Step 4b: Discover global objects (shared type definitions) await self._discover_globals() - # Step 5: Walk depth-1 (or more) and register interfaces - await self._discover_interfaces(max_depth=1) + # Step 5: Register root object only (depth-1+ resolved lazily on demand) + root_addresses = self._registry.get_root_addresses() + if root_addresses: + introspection = HamiltonIntrospection(self) + root_info = await introspection.get_object(root_addresses[0]) + root_info.children = {} + self._registry.register(root_info.name, root_info) logger.info(f"Hamilton backend setup complete. Client ID: {self._client_id}") @@ -705,52 +802,6 @@ async def _discover_globals(self): self._global_object_addresses = global_objects logger.info(f"[DISCOVER_GLOBALS] ✓ Found {len(global_objects)} global objects") - async def _discover_interfaces(self, max_depth: int = 1) -> None: - """Walk root and register objects up to max_depth. Default 1 = root + direct children.""" - root_addresses = self._registry.get_root_addresses() - if not root_addresses: - logger.warning("No root addresses; skipping interface discovery") - return - introspection = HamiltonIntrospection(self) - root_addr = root_addresses[0] - await self._register_tree(introspection, root_addr, "", max_depth) - - async def _register_tree( - self, - introspection: HamiltonIntrospection, - addr: Address, - parent_path: str, - max_depth: int, - ) -> None: - """Recursively register one node and its children up to max_depth. - - Only calls GetSubobjectAddress when the object supports it (interface 0, - method 3); otherwise skips children to avoid unsupported-method errors. - """ - info = await introspection.get_object(addr) - info.children = {} - path = info.name if not parent_path else f"{parent_path}.{info.name}" - self._registry.register(path, info) - if max_depth <= 0: - return - supported = await introspection.get_supported_interface0_method_ids(addr) - if GET_SUBOBJECT_ADDRESS not in supported: - logger.debug( - "Object %s does not support GetSubobjectAddress (interface 0, method 3); skipping children", - path, - ) - return - for i in range(info.subobject_count): - try: - sub_addr = await introspection.get_subobject_address(addr, i) - sub_info = await introspection.get_object(sub_addr) - sub_info.children = {} - sub_path = f"{path}.{sub_info.name}" - info.children[sub_info.name] = sub_info - self._registry.register(sub_path, sub_info) - await self._register_tree(introspection, sub_addr, path, max_depth - 1) - except Exception as e: - logger.debug("Failed to get subobject %s/%s: %s", path, i, e) def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: """Parse registration response options to extract object addresses. From 7f5b44b90378fe758f421855cefc09105ac1ddc1 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:06:35 -0800 Subject: [PATCH 16/42] Combined v2 aspirate/dispense logic, channel/8MPH probe, liquid-following, PrepDeck resources - Probe GetPresentChannels at setup: num_channels (1/2), has_mph; guard MPH methods - Aspirate/dispense: v2 command dispatch (NoLLD/LLD, Monitoring/TADM), optional Z from well geometry - Liquid following: SegmentDescriptor from well geometry, rectangular wells via effective radius - Tip pickup/drop: use fitting_depth; waste_position Trash for tip drop at waste sites - PrepDeck: spot size_z/child_location, teaching tip site, three waste positions - TCP backend: use error response address for TypeRegistry diagnosis --- .../backends/hamilton/prep_backend.py | 759 +++++++++++++++--- .../backends/hamilton/tcp_backend.py | 11 +- .../resources/hamilton/hamilton_decks.py | 42 +- 3 files changed, 677 insertions(+), 135 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 26e1870b7f6..f986f00836a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -19,7 +19,10 @@ from __future__ import annotations +import asyncio import logging +import math +import random from dataclasses import dataclass from enum import IntEnum from typing import TYPE_CHECKING, Annotated, List, Optional, Tuple, Union @@ -68,7 +71,10 @@ SingleChannelDispense, ) from pylabrobot.resources import Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well if TYPE_CHECKING: pass @@ -107,6 +113,13 @@ class TadmRecordingModes(IntEnum): All = 2 +class MonitoringMode(IntEnum): + """Selects aspirate monitoring vs TADM for pipetting commands.""" + + MONITORING = 0 # AspirateMonitoringParameters (default, matches v1 behavior) + TADM = 1 # TadmParameters + + # ============================================================================= # Hardware config (probed from instrument, immutable) # ============================================================================= @@ -158,6 +171,8 @@ class InstrumentConfig: deck_sites: Tuple[DeckSiteInfo, ...] waste_sites: Tuple[WasteSiteInfo, ...] default_traverse_height: Optional[float] = None # None if probe failed; user can set via set_default_traverse_height + num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels + has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels # ============================================================================= @@ -308,15 +323,21 @@ def for_op( *, flow_rate: Optional[float] = None, empty: bool = True, - z_minimum: float = -5.03, + z_minimum: float = 5.0, z_final: float = 96.97, - z_liquid_exit_speed: float = 2.0, + z_liquid_exit_speed: float = 10.0, transport_air_volume: float = 0.0, cone_height: float = 0.0, cone_bottom_radius: float = 0.0, settling_time: float = 1.0, additional_probes: int = 0, ) -> "CommonParameters": + """Build CommonParameters for a single aspirate/dispense op. + + z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has + its own size_z). High-level aspirate()/dispense() override with well bottom when None. + z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. + """ return cls( default_values=False, empty=empty, @@ -370,6 +391,10 @@ class LldParameters: z_submerge: F32 z_out_of_liquid: F32 + @classmethod + def default(cls) -> LldParameters: + return cls(default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0) + @dataclass class CLldParameters: @@ -379,6 +404,10 @@ class CLldParameters: z_clot_check: F32 detect_mode: WEnum + @classmethod + def default(cls) -> CLldParameters: + return cls(default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0) + @dataclass class PLldParameters: @@ -388,6 +417,10 @@ class PLldParameters: lld_height_difference: F32 detect_mode: WEnum + @classmethod + def default(cls) -> PLldParameters: + return cls(default_values=True, sensitivity=1, dispenser_seek_speed=0.0, lld_height_difference=0.0, detect_mode=0) + @dataclass class TadmReturnParameters: @@ -772,7 +805,7 @@ def for_op( comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of computed default (None = 0). """ - z = loc.z + tip.total_tip_length + z = loc.z + tip.total_tip_length - tip.fitting_depth z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) return cls( default_values=False, @@ -806,12 +839,14 @@ def for_op( ) -> "TipDropParameters": """Build from an op location and tip (drop). - z_seek default: z_position + total_tip_length + 10mm so tip bottom clears - adjacent tips during lateral approach. z_seek_offset: additive mm on top - of computed default (None = 0). + z_position uses (total_tip_length - fitting_depth) so the tip bottom lands + at the spot surface (consistent with STAR and with pickup). + z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during + lateral approach. z_seek_offset: additive mm on top of computed default + (None = 0). """ - z = loc.z + tip.total_tip_length - z_seek = z + tip.total_tip_length + 10.0 + (z_seek_offset or 0.0) + z = loc.z + (tip.total_tip_length - tip.fitting_depth) + z_seek = loc.z + tip.total_tip_length + 5.0 + (z_seek_offset or 0.0) return cls( default_values=False, channel=channel, @@ -858,6 +893,86 @@ class SegmentDescriptor: height: F32 +def _effective_radius(resource) -> float: + """Effective radius for CommonParameters.tube_radius. + + For circular wells uses the actual radius; for rectangular wells computes the + radius of a circle with equivalent area so tube_radius is meaningful to the + firmware's conical liquid-following model. + """ + if isinstance(resource, Well) and resource.cross_section_type == CrossSectionType.RECTANGLE: + return math.sqrt(resource.get_size_x() * resource.get_size_y() / math.pi) + return resource.get_size_x() / 2 + + +def _build_container_segments(resource) -> list[SegmentDescriptor]: + """Derive SegmentDescriptor list from a Well's geometry for liquid-following. + + Each segment is a frustum. The firmware uses area_bottom/area_top to + interpolate cross-sectional area A(z) within the segment and computes the + Z-axis following speed as dz/dt = Q / A(z), where Q is volumetric flow rate. + + Returns [] when geometry cannot be determined; the firmware then falls back to + the tube_radius / cone model in CommonParameters. + """ + if not isinstance(resource, Well): + return [] + + size_z = resource.get_size_z() + + if resource.cross_section_type == CrossSectionType.CIRCLE: + area = math.pi * (resource.get_size_x() / 2) ** 2 + elif resource.cross_section_type == CrossSectionType.RECTANGLE: + area = resource.get_size_x() * resource.get_size_y() + else: + return [] + + if resource.supports_compute_height_volume_functions(): + # Non-linear geometry: approximate with N frustum segments by sampling dV/dh. + n_boundaries = 11 # 10 segments + heights = [size_z * i / (n_boundaries - 1) for i in range(n_boundaries)] + eps = size_z / (n_boundaries - 1) * 0.1 + + def area_at(h: float) -> float: + h_lo = max(0.0, h - eps) + h_hi = min(size_z, h + eps) + dv = resource.compute_volume_from_height(h_hi) - resource.compute_volume_from_height(h_lo) + return dv / (h_hi - h_lo) + + return [ + SegmentDescriptor( + area_top=float(area_at(heights[i + 1])), + area_bottom=float(area_at(heights[i])), + height=float(heights[i + 1] - heights[i]), + ) + for i in range(n_boundaries - 1) + ] + + # Simple geometry: single segment with constant cross-section. + return [SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z))] + + +def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): + """Compute absolute Z values from well geometry for aspirate/dispense (STAR-aligned). + + Returns (well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z). The resource + must have get_size_z() (e.g. a well/container); otherwise raises ValueError. + """ + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + well_bottom_z = loc.z + op.offset.z + liquid_surface_z = well_bottom_z + (op.liquid_height or 0.0) + + if not hasattr(op.resource, "get_size_z"): + raise ValueError( + "Resource must have get_size_z() to derive absolute Z (e.g. a Well or Container). " + "Pass z_minimum, z_fluid, z_air explicitly for this operation." + ) + size_z = op.resource.get_size_z() + top_of_well_z = loc.z + size_z + z_air_z = top_of_well_z + z_air_margin_mm + return (well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) + + @dataclass class AspirateParametersNoLldAndMonitoring2: default_values: PaddedBool @@ -1505,6 +1620,14 @@ class PrepGetDeckLight(PrepCommand): """Get deck LED colour (cmd=26, dest=MLPrep).""" command_id = 26 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 @dataclass @@ -1701,6 +1824,22 @@ class Response: sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] +@dataclass +class PrepGetPresentChannels(_PrepStatusQuery): + """GetPresentChannels (cmd=17, dest=MLPrepService). + + Returns a list of enum values (iface=1, id=5): which channels are present. + Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. + Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. + """ + + command_id = 17 + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + # ============================================================================= # PrepBackend # ============================================================================= @@ -1727,11 +1866,12 @@ class PrepBackend(LiquidHandlerBackend): # Declare known object paths via InterfaceSpec. deck_config required (key positions, traverse height, deck info). _INTERFACES: dict[str, InterfaceSpec] = { - "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), - "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), - "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), - "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), - "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), + "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), + "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), + "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), + "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), + "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), + "mlprep_service": InterfaceSpec("MLPrepRoot.MLPrepService", False, True), } def __init__( @@ -1756,6 +1896,8 @@ def __init__( self._config: Optional[InstrumentConfig] = None self._user_traverse_height: Optional[float] = default_traverse_height self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) + self._num_channels: Optional[int] = None + self._has_mph: Optional[bool] = None def _has_interface(self, name: str) -> bool: """Return True if the interface was resolved and is present.""" @@ -1777,6 +1919,28 @@ async def _require(self, name: str) -> Address: """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" return await self._resolver.require(name) + async def get_present_channels(self) -> Optional[Tuple[ChannelIndex, ...]]: + """Query which channels are present (GetPresentChannels on MLPrepService). + + Maps raw enum values to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, + 2=RearChannel, 3=MPHChannel. Returns None if MLPrepService is unavailable + or the command fails (caller should use defaults). + """ + if not self._has_interface("mlprep_service"): + return None + try: + service_addr = await self._require("mlprep_service") + resp = await self.client.send_command(PrepGetPresentChannels(dest=service_addr)) + if resp is None or not getattr(resp, "channels", None): + return None + present = tuple( + ChannelIndex(v) if v in (0, 1, 2, 3) else ChannelIndex.InvalidIndex + for v in resp.channels + ) + return present + except Exception: + return None + async def setup(self, smart: bool = True, force_initialize: bool = False): """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. @@ -1822,15 +1986,19 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): logger.info("Prep initialization complete") self._config = await self._get_hardware_config() + self._num_channels = self._config.num_channels + self._has_mph = self._config.has_mph logger.info( "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " - "deck_bounds=%s, deck_sites=%d, waste_sites=%d", + "deck_bounds=%s, deck_sites=%d, waste_sites=%d, num_channels=%s, has_mph=%s", self._config.has_enclosure, self._config.safe_speeds_enabled, self._config.default_traverse_height, self._config.deck_bounds, len(self._config.deck_sites), len(self._config.waste_sites), + self._config.num_channels, + self._config.has_mph, ) # await self.ensure_spread() @@ -1852,7 +2020,11 @@ async def _run_initialize(self, smart: bool): ) async def _get_hardware_config(self) -> InstrumentConfig: - """Aggregate getters: query MLPrep and DeckConfiguration for hardware config, deck sites, and waste sites.""" + """Aggregate getters: query MLPrep, DeckConfiguration, and MLPrepService for hardware config. + + Includes deck/enclosure, deck sites, waste sites, traverse height, and channel + configuration (num_channels, has_mph) from GetPresentChannels. + """ mlprep = await self._require("mlprep") enc_resp = await self.client.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) safe_resp = await self.client.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) @@ -1907,6 +2079,16 @@ async def _get_hardware_config(self) -> InstrumentConfig: ) logger.info("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + # Channel configuration (1 vs 2 dual-channel pipettor, 8MPH) from MLPrepService + present = await self.get_present_channels() + if present is not None: + dual = [c for c in present if c in (ChannelIndex.FrontChannel, ChannelIndex.RearChannel)] + num_channels = len(dual) + has_mph = ChannelIndex.MPHChannel in present + else: + num_channels = 2 + has_mph = False + return InstrumentConfig( deck_bounds=deck_bounds, has_enclosure=has_enclosure, @@ -1914,6 +2096,8 @@ async def _get_hardware_config(self) -> InstrumentConfig: deck_sites=deck_sites, waste_sites=waste_sites, default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, ) # --------------------------------------------------------------------------- @@ -1922,19 +2106,30 @@ async def _get_hardware_config(self) -> InstrumentConfig: @property def num_channels(self) -> int: - """Prep has 2 channels (front and rear).""" - return 2 + """Number of independent dual-channel pipettor channels (1 or 2). Set at setup from GetPresentChannels.""" + if self._num_channels is None: + raise RuntimeError("num_channels not set. Call setup() first.") + return self._num_channels + + @property + def has_mph(self) -> bool: + """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Set at setup from GetPresentChannels.""" + return bool(self._has_mph) if self._has_mph is not None else False def _validate_position(self, x: float, y: float, z: float) -> None: """Raise ValueError if (x, y, z) is outside deck bounds. No-op if config/bounds not set.""" - if self._config is None or self._config.deck_bounds is None: - return - b = self._config.deck_bounds - if not (b.min_x <= x <= b.max_x and b.min_y <= y <= b.max_y and b.min_z <= z <= b.max_z): - raise ValueError( - f"Position ({x}, {y}, {z}) outside deck bounds " - f"(x=[{b.min_x}, {b.max_x}], y=[{b.min_y}, {b.max_y}], z=[{b.min_z}, {b.max_z}])" - ) + # Validation disabled for now: deck bounds logic may not match actual deck coordinates + # (e.g. well cavity_bottom Z can be below reported min_z). The instrument will reject + # invalid positions. + return + # if self._config is None or self._config.deck_bounds is None: + # return + # b = self._config.deck_bounds + # if not (b.min_x <= x <= b.max_x and b.min_y <= y <= b.max_y and b.min_z <= z <= b.max_z): + # raise ValueError( + # f"Position ({x}, {y}, {z}) outside deck bounds " + # f"(x=[{b.min_x}, {b.max_x}], y=[{b.min_y}, {b.max_y}], z=[{b.min_z}, {b.max_z}])" + # ) def _resolve_traverse_height(self, final_z: Optional[float]) -> float: """Resolve final_z: explicit arg > user-set default > probed value. Raises if none available.""" @@ -2019,13 +2214,16 @@ async def pick_up_tips( dispenser_speed: Dispenser speed for TADM (if enabled). """ assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported" + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) resolved_final_z = self._resolve_traverse_height(final_z) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipPositionParameters] = [] - for ch in range(2): + for ch in range(self.num_channels): if ch not in indexed_ops: continue op = indexed_ops[ch] @@ -2075,9 +2273,9 @@ async def drop_tips( """Drop tips. The arm moves to z_seek during lateral XY approach (tip is on pipette, so tip - bottom is at z_seek - total_tip_length). Default z_seek = z_position + - total_tip_length + 10mm so the tip bottom stays above adjacent tips in the - rack during approach. + bottom is at z_seek - (total_tip_length - fitting_depth)). z_position uses + fitting depth so the tip bottom lands at the spot surface; default z_seek = + z_position + 10mm so the tip bottom stays above adjacent tips in the rack. Args: final_z: Traverse/safe height (mm) for the move and Z position after command. @@ -2090,22 +2288,45 @@ async def drop_tips( tip_roll_off_distance: Roll-off distance (mm) for tip release. """ assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported" + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) resolved_final_z = self._resolve_traverse_height(final_z) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipDropParameters] = [] - for ch in range(2): + for ch in range(self.num_channels): if ch not in indexed_ops: continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "t") - params = TipDropParameters.for_op( - _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), - z_seek_offset=z_seek_offset, - drop_type=drop_type, + tip = op.tip + # Waste positions (Trash with category waste_position): use drop height directly + # (z_position = loc.z) instead of loc.z + tip_length, and z_seek just above. + is_waste = ( + isinstance(op.resource, Trash) + and getattr(op.resource, "category", "") == "waste_position" ) + if is_waste: + z_position = loc.z + z_seek = loc.z + 10.0 + (z_seek_offset or 0.0) + params = TipDropParameters( + default_values=False, + channel=_CHANNEL_INDEX[ch], + x_position=loc.x, + y_position=loc.y, + z_position=z_position, + z_seek=z_seek, + drop_type=drop_type, + ) + else: + params = TipDropParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) self._validate_position(loc.x, loc.y, params.z_position) tip_positions.append(params) @@ -2159,6 +2380,8 @@ async def pick_up_tips_mph( dispenser_volume: Dispenser volume for TADM (ignored when False). dispenser_speed: Dispenser speed for TADM (ignored when False). """ + if not self.has_mph: + raise RuntimeError("Instrument does not have an 8MPH head. Cannot use pick_up_tips_mph.") if isinstance(tip_spot, list): spots = tip_spot else: @@ -2225,6 +2448,8 @@ async def drop_tips_mph( drop_type: How tips are released (FixedHeight, Stall, or CLLDSeek). tip_roll_off_distance: Roll-off distance (mm) for tip release. """ + if not self.has_mph: + raise RuntimeError("Instrument does not have an 8MPH head. Cannot use drop_tips_mph.") if isinstance(tip_spot, list): spots = tip_spot else: @@ -2257,129 +2482,334 @@ async def aspirate( self, ops: List[SingleChannelAspiration], use_channels: List[int], - z_final: float = 96.97, - z_fluid: float = 94.97, - z_air: float = 96.97, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, settling_time: float = 1.0, transport_air_volume: float = 0.0, - z_liquid_exit_speed: float = 2.0, - z_minimum: float = -5.03, + z_liquid_exit_speed: float = 10.0, + z_minimum: Optional[float] = None, z_bottom_search_offset: float = 2.0, + monitoring_mode: MonitoringMode = MonitoringMode.MONITORING, + use_lld: bool = False, + lld: Optional[LldParameters] = None, + p_lld: Optional[PLldParameters] = None, + c_lld: Optional[CLldParameters] = None, + tadm: Optional[TadmParameters] = None, + container_segments: Optional[List[List[SegmentDescriptor]]] = None, # TODO: Doesn't work with No LLD + auto_container_geometry: bool = False, ): - """Aspirate from the given resources (NoLLD path). + """Aspirate using v2 commands, dispatching to the appropriate variant. + + Selects the command variant based on ``use_lld`` / ``lld`` (LLD on/off) and + ``monitoring_mode`` (Monitoring vs TADM). When z_minimum, z_final, z_fluid, + or z_air are None, they are derived from well geometry (absolute coordinates, + STAR-aligned): z_minimum = well bottom, z_fluid = well bottom + op.liquid_height, + z_air = above labware + 2 mm, z_final = Prep traverse height minus fitted tip length (when None). - All optional kwargs override wire-protocol defaults and are passed through - to CommonParameters and NoLldParameters. Example:: + Args: + z_final: Z after the move (retract height). If None, uses bare-channel traverse height + minus max fitted tip length across channels (so the instrument ends at traverse height). + z_fluid: Liquid surface Z when not using LLD. If None, derived as well_bottom + op.liquid_height. + z_air: Z in air (above liquid). If None, derived as top of well + 2 mm. + z_minimum: Minimum Z (well floor). If None, derived as well bottom. + monitoring_mode: Select TADM or Monitoring (default: Monitoring). + use_lld: Enable LLD aspirate variant. Also activated if ``lld`` is set. + lld: LLD seek parameters. When None and use_lld=True, built from labware geometry + (z_seek = top of well; z_submerge/z_out_of_liquid = relative offsets). + p_lld: Pressure LLD parameters (LLD variants only). + c_lld: Capacitive LLD parameters (LLD variants only). + tadm: TADM parameters (TADM variants only). Firmware defaults when None. + container_segments: Per-channel SegmentDescriptor lists for liquid following. + If None and auto_container_geometry=True, derived from well geometry. + auto_container_geometry: Automatically build container segments from the + well's cross-section geometry. Pass False to use empty segments + (firmware falls back to the CommonParameters cone model). + + Example:: await backend.aspirate(ops, [0], z_final=95.0, settling_time=2.0) + await backend.aspirate(ops, [0], use_lld=True) + await backend.aspirate(ops, [0], monitoring_mode=MonitoringMode.TADM) """ assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported" + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + effective_lld = use_lld or (lld is not None) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - aspirate_parameters: List[AspirateParametersNoLldAndMonitoring] = [] - for ch in range(2): + + # Resolve z_final: when None, use bare-channel traverse height minus fitted tip length + # so the instrument (which may add tip length) ends with channel at traverse height. + raw_traverse = self._resolve_traverse_height(z_final) + if z_final is None and indexed_ops: + max_fitted_length = max( + op.tip.total_tip_length - op.tip.fitting_depth for op in indexed_ops.values() + ) + resolved_z_final = raw_traverse - max_fitted_length + else: + resolved_z_final = raw_traverse + + # Build per-channel segment lists. + ch_segments: dict[int, list[SegmentDescriptor]] = {} + for i, ch in enumerate(use_channels): + if container_segments is not None and i < len(container_segments): + ch_segments[ch] = container_segments[i] + elif auto_container_geometry: + ch_segments[ch] = _build_container_segments(indexed_ops[ch].resource) + else: + ch_segments[ch] = [] + + _p_lld = p_lld or PLldParameters.default() + _c_lld = c_lld or CLldParameters.default() + _tadm = tadm or TadmParameters.default() + + params_lld_mon: List[AspirateParametersLldAndMonitoring2] = [] + params_lld_tadm: List[AspirateParametersLldAndTadm2] = [] + params_nolld_mon: List[AspirateParametersNoLldAndMonitoring2] = [] + params_nolld_tadm: List[AspirateParametersNoLldAndTadm2] = [] + + for ch in range(self.num_channels): if ch not in indexed_ops: continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") self._validate_position(loc.x, loc.y, loc.z) - assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" - radius = op.resource.get_size_x() / 2 - aspirate_parameters.append( - AspirateParametersNoLldAndMonitoring( + radius = _effective_radius(op.resource) + asp = AspirateParameters.for_op(loc, op) + segs = ch_segments[ch] + + well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) + z_minimum_ch = z_minimum if z_minimum is not None else well_bottom_z + z_final_ch = resolved_z_final + z_fluid_ch = z_fluid if z_fluid is not None else liquid_surface_z + z_air_ch = z_air if z_air is not None else z_air_z + + if effective_lld and lld is None: + _lld = LldParameters( + default_values=False, + z_seek=top_of_well_z, + z_seek_speed=0.0, + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + else: + _lld = lld or LldParameters.default() + + common = CommonParameters.for_op( + op.volume, radius, + flow_rate=op.flow_rate, + z_minimum=z_minimum_ch, + z_final=z_final_ch, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + no_lld = NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset) + + if effective_lld and monitoring_mode == MonitoringMode.TADM: + params_lld_tadm.append(AspirateParametersLldAndTadm2( default_values=False, channel=_CHANNEL_INDEX[ch], - aspirate=AspirateParameters.for_op(loc, op), - common=CommonParameters.for_op( - op.volume, radius, - flow_rate=op.flow_rate, - z_minimum=z_minimum, - z_final=z_final, - z_liquid_exit_speed=z_liquid_exit_speed, - transport_air_volume=transport_air_volume, - settling_time=settling_time, - ), - no_lld=NoLldParameters.for_fixed_z( - z_fluid, z_air, - z_bottom_search_offset=z_bottom_search_offset, - ), + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, p_lld=_p_lld, c_lld=_c_lld, mix=MixParameters.default(), + tadm=_tadm, adc=AdcParameters.default(), + )) + elif effective_lld: + params_lld_mon.append(AspirateParametersLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, p_lld=_p_lld, c_lld=_c_lld, + mix=MixParameters.default(), aspirate_monitoring=AspirateMonitoringParameters.default(), - ) - ) - - await self.client.send_command( - PrepAspirateNoLldMonitoring( - dest=await self._require("pipettor"), - aspirate_parameters=aspirate_parameters, - ) - ) + adc=AdcParameters.default(), + )) + elif monitoring_mode == MonitoringMode.TADM: + params_nolld_tadm.append(AspirateParametersNoLldAndTadm2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=MixParameters.default(), + adc=AdcParameters.default(), + tadm=_tadm, + )) + else: + params_nolld_mon.append(AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=MixParameters.default(), + adc=AdcParameters.default(), + aspirate_monitoring=AspirateMonitoringParameters.default(), + )) + + dest = await self._require("pipettor") + if effective_lld and monitoring_mode == MonitoringMode.TADM: + await self.client.send_command(PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm)) + elif effective_lld: + await self.client.send_command(PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon)) + elif monitoring_mode == MonitoringMode.TADM: + await self.client.send_command(PrepAspirateTadmV2(dest=dest, aspirate_parameters=params_nolld_tadm)) + else: + await self.client.send_command(PrepAspirateNoLldMonitoringV2(dest=dest, aspirate_parameters=params_nolld_mon)) async def dispense( self, ops: List[SingleChannelDispense], use_channels: List[int], - final_z: float = 96.97, - z_fluid: float = 94.97, - z_air: float = 99.08, + final_z: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, settling_time: float = 0.0, transport_air_volume: float = 0.0, - z_liquid_exit_speed: float = 2.0, - z_minimum: float = -5.03, + z_liquid_exit_speed: float = 10.0, + z_minimum: Optional[float] = None, z_bottom_search_offset: float = 2.0, + use_lld: bool = False, + lld: Optional[LldParameters] = None, + c_lld: Optional[CLldParameters] = None, + container_segments: Optional[List[List[SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD ): - """Dispense to the given resources (NoLLD path). + """Dispense using v2 commands, dispatching to NoLLD or LLD variant. + + When final_z, z_minimum, z_fluid, or z_air are None, they are derived from well + geometry (absolute coordinates, STAR-aligned): z_minimum = well bottom, + z_fluid = well bottom + op.liquid_height, z_air = above labware + 2 mm, + final_z = Prep traverse height minus fitted tip length (when None). - All optional kwargs override wire-protocol defaults and are passed through - to CommonParameters and NoLldParameters. Example:: + Args: + final_z: Z after the move. If None, uses bare-channel traverse height minus max fitted tip length. + z_fluid: Liquid surface Z when not using LLD. If None, derived as well_bottom + op.liquid_height. + z_air: Z in air (above liquid). If None, derived as top of well + 2 mm. + z_minimum: Minimum Z (well floor). If None, derived as well bottom. + use_lld: Enable LLD dispense variant. Also activated if ``lld`` is set. + lld: LLD seek parameters. When None and use_lld=True, built from labware geometry. + c_lld: Capacitive LLD parameters (LLD variant only). + container_segments: Per-channel SegmentDescriptor lists for liquid following. + auto_container_geometry: Automatically build container segments from well geometry. + + Example:: await backend.dispense(ops, [0], final_z=95.0, settling_time=0.5) + await backend.dispense(ops, [0], use_lld=True) """ assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported" + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + effective_lld = use_lld or (lld is not None) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - dispense_parameters: List[DispenseParametersNoLld] = [] - for ch in range(2): + + # Resolve z_final: when None, use bare-channel traverse height minus fitted tip length. + raw_traverse = self._resolve_traverse_height(final_z) + if final_z is None and indexed_ops: + max_fitted_length = max( + op.tip.total_tip_length - op.tip.fitting_depth for op in indexed_ops.values() + ) + resolved_z_final = raw_traverse - max_fitted_length + else: + resolved_z_final = raw_traverse + + ch_segments: dict[int, list[SegmentDescriptor]] = {} + for i, ch in enumerate(use_channels): + if container_segments is not None and i < len(container_segments): + ch_segments[ch] = container_segments[i] + elif auto_container_geometry: + ch_segments[ch] = _build_container_segments(indexed_ops[ch].resource) + else: + ch_segments[ch] = [] + + _c_lld = c_lld or CLldParameters.default() + + params_nolld: List[DispenseParametersNoLld2] = [] + params_lld: List[DispenseParametersLld2] = [] + + for ch in range(self.num_channels): if ch not in indexed_ops: continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") self._validate_position(loc.x, loc.y, loc.z) - assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round wells supported" - radius = op.resource.get_size_x() / 2 - dispense_parameters.append( - DispenseParametersNoLld( + radius = _effective_radius(op.resource) + disp = DispenseParameters.for_op(loc) + segs = ch_segments[ch] + + well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) + z_minimum_ch = z_minimum if z_minimum is not None else well_bottom_z + z_final_ch = resolved_z_final + z_fluid_ch = z_fluid if z_fluid is not None else liquid_surface_z + z_air_ch = z_air if z_air is not None else z_air_z + + if effective_lld and lld is None: + _lld = LldParameters( + default_values=False, + z_seek=top_of_well_z, + z_seek_speed=0.0, + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + else: + _lld = lld or LldParameters.default() + + common = CommonParameters.for_op( + op.volume, radius, + flow_rate=op.flow_rate, + z_minimum=z_minimum_ch, + z_final=z_final_ch, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + + if effective_lld: + params_lld.append(DispenseParametersLld2( default_values=False, channel=_CHANNEL_INDEX[ch], - dispense=DispenseParameters.for_op(loc), - common=CommonParameters.for_op( - op.volume, radius, - flow_rate=op.flow_rate, - z_minimum=z_minimum, - z_final=final_z, - z_liquid_exit_speed=z_liquid_exit_speed, - transport_air_volume=transport_air_volume, - settling_time=settling_time, - ), - no_lld=NoLldParameters.for_fixed_z( - z_fluid, z_air, - z_bottom_search_offset=z_bottom_search_offset, - ), + dispense=disp, + container_description=segs, + common=common, + lld=_lld, + c_lld=_c_lld, mix=MixParameters.default(), + adc=AdcParameters.default(), tadm=TadmParameters.default(), + )) + else: + params_nolld.append(DispenseParametersNoLld2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=disp, + container_description=segs, + common=common, + no_lld=NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset), + mix=MixParameters.default(), adc=AdcParameters.default(), - ) - ) + tadm=TadmParameters.default(), + )) - await self.client.send_command( - PrepDispenseNoLld( - dest=await self._require("pipettor"), - dispense_parameters=dispense_parameters, - ) - ) + dest = await self._require("pipettor") + if effective_lld: + await self.client.send_command(PrepDispenseWithLldV2(dest=dest, dispense_parameters=params_lld)) + else: + await self.client.send_command(PrepDispenseNoLldV2(dest=dest, dispense_parameters=params_nolld)) async def pick_up_tips96(self, pickup: PickupTipRack): raise NotImplementedError("pick_up_tips96 is not supported on the Prep") @@ -2407,6 +2837,17 @@ async def drop_resource(self, drop: ResourceDrop): raise NotImplementedError("drop_resource is not yet implemented on the Prep") def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel. + + Uses the same logic as Nimbus/STAR: only Hamilton tips, no XL tips, + and channel index must be valid. + """ + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + if self._num_channels is not None and channel_idx >= self._num_channels: + return False return True # --------------------------------------------------------------------------- @@ -2438,6 +2879,15 @@ async def method_abort(self) -> None: """Abort the current method.""" await self.client.send_command(PrepMethodAbort(dest=await self._require("mlprep"))) + async def get_deck_light(self) -> Tuple[int, int, int, int]: + """Get the current deck LED colour (white, red, green, blue).""" + result = await self.client.send_command( + PrepGetDeckLight(dest=await self._require("mlprep")) + ) + if result is None: + raise ValueError("No response from GetDeckLight.") + return (result.white, result.red, result.green, result.blue) + async def set_deck_light( self, white: int, red: int, green: int, blue: int ) -> None: @@ -2452,35 +2902,84 @@ async def set_deck_light( ) ) + async def disco_mode(self) -> None: + """Easter egg: cycle deck lights then restore previous state.""" + white, red, green, blue = await self.get_deck_light() + try: + for _ in range(69): + await self.set_deck_light( + white=random.randint(1, 255), + red=random.randint(1, 255), + green=random.randint(1, 255), + blue=random.randint(1, 255), + ) + await asyncio.sleep(0.1) + finally: + await self.set_deck_light(white=white, red=red, green=green, blue=blue) + # --------------------------------------------------------------------------- # Pipettor convenience methods # --------------------------------------------------------------------------- - async def move_to_position(self, move_parameters: GantryMoveXYZParameters) -> None: - """Move to position (cmd=26).""" - for ax in move_parameters.axis_parameters: - self._validate_position( - move_parameters.gantry_x_position, ax.y_position, ax.z_position + async def move_to_position( + self, + x: float, + y: Union[float, List[float]], + z: Union[float, List[float]], + use_channels: Union[int, List[int]] = 0, + *, + via_lane: bool = False, + ) -> None: + """Move pipettor to position (cmd=26 or 27). Same (x,y,z) params; via_lane selects cmd 27. + + use_channels defaults to 0 (rear channel). Pass a single channel index (int) or + a list of indices; for all channels use list(range(self.num_channels)). For a + single channel, y and z may be scalars instead of lists. + """ + channels = [use_channels] if isinstance(use_channels, int) else list(use_channels) + channels = sorted(channels) + if channels: + assert max(channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) - await self.client.send_command( - PrepMoveToPosition( - dest=await self._require("pipettor"), - move_parameters=move_parameters, + if isinstance(y, list): + assert len(y) == len(channels), "len(y) must equal len(use_channels)" + if isinstance(z, list): + assert len(z) == len(channels), "len(z) must equal len(use_channels)" + + axis_parameters: List[ChannelYZMoveParameters] = [] + for i, ch in enumerate(channels): + y_i = y if isinstance(y, (int, float)) else y[i] + z_i = z if isinstance(z, (int, float)) else z[i] + self._validate_position(x, y_i, z_i) + axis_parameters.append( + ChannelYZMoveParameters( + default_values=False, + channel=_CHANNEL_INDEX[ch], + y_position=y_i, + z_position=z_i, + ) ) + move_parameters = GantryMoveXYZParameters( + default_values=False, + gantry_x_position=x, + axis_parameters=axis_parameters, ) - async def move_to_position_via_lane(self, move_parameters: GantryMoveXYZParameters) -> None: - """Move to position via lane (cmd=27).""" - for ax in move_parameters.axis_parameters: - self._validate_position( - move_parameters.gantry_x_position, ax.y_position, ax.z_position + if via_lane: + await self.client.send_command( + PrepMoveToPositionViaLane( + dest=await self._require("pipettor"), + move_parameters=move_parameters, + ) ) - await self.client.send_command( - PrepMoveToPositionViaLane( - dest=await self._require("pipettor"), - move_parameters=move_parameters, + else: + await self.client.send_command( + PrepMoveToPosition( + dest=await self._require("pipettor"), + move_parameters=move_parameters, + ) ) - ) async def stop(self) -> None: await self.client.stop() diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index de8ff87d6ca..1f0f6fad9d8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -946,14 +946,17 @@ async def send_command( if not self.detailed_errors: raise RuntimeError(enriched_msg) # Layer B: async TypeRegistry diagnosis (method signature, expected params) + # Use the address from the error response so we resolve the method on the object that threw intro = HamiltonIntrospection(self) - addr = command.dest_address - if addr not in self._type_registries: + error_addr = parsed_addr[0] if parsed_addr else command.dest_address + if error_addr not in self._type_registries: try: - self._type_registries[addr] = await intro.build_type_registry(addr) + self._type_registries[error_addr] = await intro.build_type_registry(error_addr) except Exception: raise RuntimeError(enriched_msg) - diagnostic = await intro.diagnose_error(enriched_msg, self._type_registries[addr]) + diagnostic = await intro.diagnose_error( + enriched_msg, self._type_registries[error_addr] + ) raise RuntimeError(diagnostic) logger.debug(enriched_msg) return None diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 4566ba195e6..a9edfa47314 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -623,6 +623,13 @@ def STARDeck( class PrepDeck(Deck): + """Hamilton PREP deck with spots, trash, teaching tip site, and waste positions. + + Includes a teaching tip site (deck site id=2: 6x6x85 mm), using the same 300uL tip + definition as the STAR teaching rack, and three waste positions (two for dual-channel + pipettor, one for 8MPH) from DeckConfiguration waste site definitions. + """ + def __init__( self, name="deck", size_x=0, size_y=0.5, size_z=0, origin=Coordinate.zero(), category="deck" ): @@ -637,7 +644,8 @@ def __init__( name=f"spot_{column}_{row}", size_x=127.76, size_y=92, - size_z=0, + size_z=12.5, + child_location=Coordinate(0, 0, 3.75), ) self.assign_child_resource(spot, location=Coordinate(x, y, 0)) @@ -645,6 +653,38 @@ def __init__( # TODO: y coordinate self.assign_child_resource(trash, location=Coordinate(287.0, 0, 0)) + # Same tip definition as STAR teaching rack: 300uL tip. Backend sends z_position = + # Slot height=83 mm (measured from tip top); 300uL filter: total_tip_length=59.9 mm + fine adjustment + teaching_tip_spot = TipSpot( + name="teaching_tip", + size_x=6.0, + size_y=6.0, + make_tip=hamilton_tip_300uL_filter, + size_z=0.0, + category="teaching_tip", + ) + self.assign_child_resource( + teaching_tip_spot, + location=Coordinate(x=284.76, y=214.29, z=23.85), + ) + + # Waste positions from DeckConfiguration.GetWasteSiteDefinitions (index 1, 2, 3). + # Names match ChannelIndex / use_channels: 0=RearChannel, 1=FrontChannel, MPH separate. + # Channel 1 (Front): (286.8, 10, 68.4), Channel 2 (Rear): (286.8, 30, 68.4), 8MPH: (286.8, 112, 68.4) + # Assign with size (0, 0, 68.4) so get_absolute_location(..., z="t") gives drop height 68.4 + for waste_name, y_pos in [("waste_rear", 30.0), ("waste_front", 10.0), ("waste_mph", 112.0)]: + waste = Trash( + name=waste_name, + size_x=0.0, + size_y=0.0, + size_z=68.4, + category="waste_position", + ) + self.assign_child_resource( + waste, + location=Coordinate(x=286.8, y=y_pos, z=0.0), + ) + def __getitem__(self, key: int) -> ResourceHolder: return self.children[key] From e4545376adc166e2b38f41a1b26fd7b5247ebb57 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:20:03 -0800 Subject: [PATCH 17/42] Formatting --- .../backends/hamilton/nimbus_backend_tests.py | 1 - pylabrobot/liquid_handling/backends/hamilton/prep_tests.py | 2 +- pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py | 1 - .../liquid_handling/backends/hamilton/tcp/introspection.py | 5 +---- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index decf1e874c8..4f6fc7f913c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -6,7 +6,6 @@ import unittest import unittest.mock -from typing import Optional from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import ( Aspirate, diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py index 89781370fa0..04e1d624795 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py @@ -25,7 +25,7 @@ def test_decode_ip_packet(self): assert ip_packet.protocol == 6 assert ip_packet.version == (3, 0) assert ip_packet.options_length == 0 - assert ip_packet.options == None + assert ip_packet.options is None assert ip_packet.payload == self.harp_packet_data def test_encode_ip_packet(self): diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py index 88db123cb77..ec45345526d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py @@ -17,7 +17,6 @@ ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol -from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import I32 class HamiltonCommand: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 6fd956bc292..9cdbb9d9a5a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -24,7 +24,7 @@ import logging from dataclasses import dataclass, field -from typing import Annotated, Dict, List, Optional, Set, Union +from typing import Dict, List, Optional, Set, Union from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( @@ -38,7 +38,6 @@ from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( HamiltonDataType, I8Array, - I32, I32Array, Str, StrArray, @@ -1475,7 +1474,6 @@ def _get_wire_type_id(annotation) -> Optional[int]: Returns None if the annotation doesn't carry a WireType. """ - origin = getattr(annotation, "__class__", None) # Handle typing.Annotated metadata = getattr(annotation, "__metadata__", None) if metadata: @@ -1487,7 +1485,6 @@ def _get_wire_type_id(annotation) -> Optional[int]: def _get_nested_dataclass(annotation): """For Annotated[SomeDataclass, Struct()], return SomeDataclass. Else None.""" - import typing args = getattr(annotation, "__args__", None) if not args: return None From 7b539419d9f5d2d411e3f12f8f24d82f7f2a7561 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:28:54 -0800 Subject: [PATCH 18/42] Prep Liquid Classes and default/override logic (Star Calibration Curves) --- .../backends/hamilton/prep_backend.py | 341 +++++++++++++----- 1 file changed, 260 insertions(+), 81 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index f986f00836a..17d62253cd4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -49,6 +49,7 @@ Enum as WEnum, ) from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( HamiltonTCPClient, @@ -70,8 +71,13 @@ SingleChannelAspiration, SingleChannelDispense, ) +from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) from pylabrobot.resources import Tip from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.liquid import Liquid from pylabrobot.resources.tip_rack import TipSpot from pylabrobot.resources.trash import Trash from pylabrobot.resources.well import CrossSectionType, Well @@ -265,13 +271,14 @@ def for_op( loc, op: "SingleChannelAspiration", prewet_volume: float = 0.0, + blowout_volume: Optional[float] = None, ) -> "AspirateParameters": return cls( default_values=False, x_position=loc.x, y_position=loc.y, prewet_volume=prewet_volume, - blowout_volume=op.blow_out_air_volume or 0.0, + blowout_volume=(op.blow_out_air_volume or 0.0) if blowout_volume is None else blowout_volume, ) @@ -2482,14 +2489,15 @@ async def aspirate( self, ops: List[SingleChannelAspiration], use_channels: List[int], - z_final: Optional[float] = None, - z_fluid: Optional[float] = None, - z_air: Optional[float] = None, - settling_time: float = 1.0, - transport_air_volume: float = 0.0, - z_liquid_exit_speed: float = 10.0, - z_minimum: Optional[float] = None, - z_bottom_search_offset: float = 2.0, + z_final: Optional[List[Optional[float]]] = None, + z_fluid: Optional[List[Optional[float]]] = None, + z_air: Optional[List[Optional[float]]] = None, + settling_time: Optional[List[Optional[float]]] = None, + transport_air_volume: Optional[List[Optional[float]]] = None, + z_liquid_exit_speed: Optional[List[Optional[float]]] = None, + prewet_volume: Optional[List[Optional[float]]] = None, + z_minimum: Optional[List[Optional[float]]] = None, + z_bottom_search_offset: Optional[List[Optional[float]]] = None, monitoring_mode: MonitoringMode = MonitoringMode.MONITORING, use_lld: bool = False, lld: Optional[LldParameters] = None, @@ -2498,21 +2506,33 @@ async def aspirate( tadm: Optional[TadmParameters] = None, container_segments: Optional[List[List[SegmentDescriptor]]] = None, # TODO: Doesn't work with No LLD auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, ): """Aspirate using v2 commands, dispatching to the appropriate variant. Selects the command variant based on ``use_lld`` / ``lld`` (LLD on/off) and - ``monitoring_mode`` (Monitoring vs TADM). When z_minimum, z_final, z_fluid, - or z_air are None, they are derived from well geometry (absolute coordinates, - STAR-aligned): z_minimum = well bottom, z_fluid = well bottom + op.liquid_height, - z_air = above labware + 2 mm, z_final = Prep traverse height minus fitted tip length (when None). + ``monitoring_mode`` (Monitoring vs TADM). Z/geometry parameters (z_final, + z_fluid, z_air, z_minimum, z_bottom_search_offset): when None, default lists + are derived from well geometry (absolute coordinates, STAR-aligned). When a + list is passed, length must match len(ops); each element overrides that channel + (list entry None = use default for that channel). + + Liquid-class-derived parameters (settling_time, transport_air_volume, + z_liquid_exit_speed, prewet_volume): when None, HLC (or fallback) is used per channel. + When a list is passed, length must match len(ops); each element overrides the HLC for that + channel (list entry None = use HLC/fallback for that channel). Args: - z_final: Z after the move (retract height). If None, uses bare-channel traverse height - minus max fitted tip length across channels (so the instrument ends at traverse height). - z_fluid: Liquid surface Z when not using LLD. If None, derived as well_bottom + op.liquid_height. - z_air: Z in air (above liquid). If None, derived as top of well + 2 mm. - z_minimum: Minimum Z (well floor). If None, derived as well bottom. + z_final: Z after the move (retract height) per channel. None = traverse minus fitted tip length per op. + z_fluid: Liquid surface Z when not using LLD, per channel. None = well_bottom + op.liquid_height. + z_air: Z in air (above liquid), per channel. None = top of well + 2 mm. + settling_time: Settling time (s) per channel. None = liquid class or fallback 1.0 per channel. + transport_air_volume: Transport air volume (µL) per channel. None = liquid class or fallback 0.0. + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = liquid class or fallback 10.0. + prewet_volume: Pre-wet volume (µL) per channel. None = liquid class or fallback 0.0. + z_minimum: Minimum Z (well floor) per channel. None = well bottom. + z_bottom_search_offset: Bottom search offset (mm) per channel. None = 2.0 per channel. monitoring_mode: Select TADM or Monitoring (default: Monitoring). use_lld: Enable LLD aspirate variant. Also activated if ``lld`` is set. lld: LLD seek parameters. When None and use_lld=True, built from labware geometry @@ -2525,10 +2545,14 @@ async def aspirate( auto_container_geometry: Automatically build container segments from the well's cross-section geometry. Pass False to use empty segments (firmware falls back to the CommonParameters cone model). + hamilton_liquid_classes: Optional list of Hamilton liquid classes, one per op. + When None, defaults are built per op via get_star_liquid_class (same as STAR). + When provided, length must match len(ops). + disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. Example:: - await backend.aspirate(ops, [0], z_final=95.0, settling_time=2.0) + await backend.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) await backend.aspirate(ops, [0], use_lld=True) await backend.aspirate(ops, [0], monitoring_mode=MonitoringMode.TADM) """ @@ -2538,19 +2562,77 @@ async def aspirate( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) + n = len(ops) + if hamilton_liquid_classes is not None: + if len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = list(hamilton_liquid_classes) + else: + # Defaults from STAR calibration table; add get_prep_liquid_class if Prep needs different values. + hlcs = [ + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + for op in ops + ] + disable_volume_correction = disable_volume_correction if disable_volume_correction is not None else [False] * n + if len(disable_volume_correction) != n: + raise ValueError( + f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" + ) + ch_to_hlc = {ch: hlcs[i] for i, ch in enumerate(use_channels)} + ch_to_disable = {ch: disable_volume_correction[i] for i, ch in enumerate(use_channels)} + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + + # Default lists from HLC (fallbacks when HLC is None) + default_settling = [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] + default_transport_air_volume = [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs] + default_z_liquid_exit_speed = [hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + default_prewet_volume = [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs] + settling_time = fill_in_defaults(settling_time, default_settling) + transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) + z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) + prewet_volume = fill_in_defaults(prewet_volume, default_prewet_volume) + + 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.0) + for op, hlc in zip(ops, hlcs) + ] + blowout_volumes = [ + op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + for op, hlc in zip(ops, hlcs) + ] + effective_lld = use_lld or (lld is not None) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - # Resolve z_final: when None, use bare-channel traverse height minus fitted tip length - # so the instrument (which may add tip length) ends with channel at traverse height. - raw_traverse = self._resolve_traverse_height(z_final) - if z_final is None and indexed_ops: - max_fitted_length = max( - op.tip.total_tip_length - op.tip.fitting_depth for op in indexed_ops.values() - ) - resolved_z_final = raw_traverse - max_fitted_length - else: - resolved_z_final = raw_traverse + # Precompute well geometry once (used for default Z lists and for LLD in the loop). + well_geometry = [_absolute_z_from_well(op) for op in ops] + default_z_minimum = [g[0] for g in well_geometry] + default_z_fluid = [g[1] for g in well_geometry] + default_z_air = [g[3] for g in well_geometry] + raw_traverse = self._resolve_traverse_height(None) + default_z_final = [ + raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops + ] + default_z_bottom_search_offset = [2.0] * n + z_minimum = fill_in_defaults(z_minimum, default_z_minimum) + z_fluid = fill_in_defaults(z_fluid, default_z_fluid) + z_air = fill_in_defaults(z_air, default_z_air) + z_final = fill_in_defaults(z_final, default_z_final) + z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, default_z_bottom_search_offset) # Build per-channel segment lists. ch_segments: dict[int, list[SegmentDescriptor]] = {} @@ -2574,20 +2656,27 @@ async def aspirate( for ch in range(self.num_channels): if ch not in indexed_ops: continue + idx = ch_to_idx[ch] op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") self._validate_position(loc.x, loc.y, loc.z) radius = _effective_radius(op.resource) - asp = AspirateParameters.for_op(loc, op) + asp = AspirateParameters.for_op( + loc, + op, + prewet_volume=prewet_volume[idx], + blowout_volume=blowout_volumes[idx], + ) segs = ch_segments[ch] - well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) - z_minimum_ch = z_minimum if z_minimum is not None else well_bottom_z - z_final_ch = resolved_z_final - z_fluid_ch = z_fluid if z_fluid is not None else liquid_surface_z - z_air_ch = z_air if z_air is not None else z_air_z + z_minimum_ch = z_minimum[idx] + z_final_ch = z_final[idx] + z_fluid_ch = z_fluid[idx] + z_air_ch = z_air[idx] + z_bottom_search_offset_ch = z_bottom_search_offset[idx] if effective_lld and lld is None: + top_of_well_z = well_geometry[idx][2] _lld = LldParameters( default_values=False, z_seek=top_of_well_z, @@ -2599,15 +2688,16 @@ async def aspirate( _lld = lld or LldParameters.default() common = CommonParameters.for_op( - op.volume, radius, - flow_rate=op.flow_rate, + volumes[idx], + radius, + flow_rate=flow_rates[idx], z_minimum=z_minimum_ch, z_final=z_final_ch, - z_liquid_exit_speed=z_liquid_exit_speed, - transport_air_volume=transport_air_volume, - settling_time=settling_time, + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], ) - no_lld = NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset) + no_lld = NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch) if effective_lld and monitoring_mode == MonitoringMode.TADM: params_lld_tadm.append(AspirateParametersLldAndTadm2( @@ -2672,41 +2762,60 @@ async def dispense( self, ops: List[SingleChannelDispense], use_channels: List[int], - final_z: Optional[float] = None, - z_fluid: Optional[float] = None, - z_air: Optional[float] = None, - settling_time: float = 0.0, - transport_air_volume: float = 0.0, - z_liquid_exit_speed: float = 10.0, - z_minimum: Optional[float] = None, - z_bottom_search_offset: float = 2.0, + final_z: Optional[List[Optional[float]]] = None, + z_fluid: Optional[List[Optional[float]]] = None, + z_air: Optional[List[Optional[float]]] = None, + settling_time: Optional[List[Optional[float]]] = None, + transport_air_volume: Optional[List[Optional[float]]] = None, + z_liquid_exit_speed: Optional[List[Optional[float]]] = None, + stop_back_volume: Optional[List[Optional[float]]] = None, + cutoff_speed: Optional[List[Optional[float]]] = None, + z_minimum: Optional[List[Optional[float]]] = None, + z_bottom_search_offset: Optional[List[Optional[float]]] = None, use_lld: bool = False, lld: Optional[LldParameters] = None, c_lld: Optional[CLldParameters] = None, container_segments: Optional[List[List[SegmentDescriptor]]] = None, auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, ): """Dispense using v2 commands, dispatching to NoLLD or LLD variant. - When final_z, z_minimum, z_fluid, or z_air are None, they are derived from well - geometry (absolute coordinates, STAR-aligned): z_minimum = well bottom, - z_fluid = well bottom + op.liquid_height, z_air = above labware + 2 mm, - final_z = Prep traverse height minus fitted tip length (when None). + Z/geometry parameters (final_z, z_fluid, z_air, z_minimum, z_bottom_search_offset): + when None, default lists are derived from well geometry (absolute coordinates, + STAR-aligned). When a list is passed, length must match len(ops); each element + overrides that channel (list entry None = use default for that channel). + + Liquid-class-derived parameters (settling_time, transport_air_volume, + z_liquid_exit_speed, stop_back_volume, cutoff_speed): when None, HLC (or fallback) + is used per channel. When a list is passed, length must match len(ops); each + element overrides the HLC for that channel (list entry None = use HLC/fallback). Args: - final_z: Z after the move. If None, uses bare-channel traverse height minus max fitted tip length. - z_fluid: Liquid surface Z when not using LLD. If None, derived as well_bottom + op.liquid_height. - z_air: Z in air (above liquid). If None, derived as top of well + 2 mm. - z_minimum: Minimum Z (well floor). If None, derived as well bottom. + final_z: Z after the move per channel. None = traverse minus fitted tip length per op. + z_fluid: Liquid surface Z when not using LLD, per channel. None = well_bottom + op.liquid_height. + z_air: Z in air (above liquid), per channel. None = top of well + 2 mm. + settling_time: Settling time (s) per channel. None = liquid class or fallback 0.0 per channel. + transport_air_volume: Transport air volume (µL) per channel. None = liquid class or fallback 0.0. + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = liquid class or fallback 10.0. + stop_back_volume: Stop-back volume (µL) per channel. None = liquid class or fallback 0.0. + cutoff_speed: Cutoff/stop flow rate (µL/s) per channel. None = liquid class or fallback 100.0. + z_minimum: Minimum Z (well floor) per channel. None = well bottom. + z_bottom_search_offset: Bottom search offset (mm) per channel. None = 2.0 per channel. use_lld: Enable LLD dispense variant. Also activated if ``lld`` is set. lld: LLD seek parameters. When None and use_lld=True, built from labware geometry. c_lld: Capacitive LLD parameters (LLD variant only). container_segments: Per-channel SegmentDescriptor lists for liquid following. auto_container_geometry: Automatically build container segments from well geometry. + hamilton_liquid_classes: Optional list of Hamilton liquid classes, one per op. + When None, defaults are built per op via get_star_liquid_class (same as STAR). + When provided, length must match len(ops). + disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. Example:: - await backend.dispense(ops, [0], final_z=95.0, settling_time=0.5) + await backend.dispense(ops, [0], final_z=[95.0], settling_time=[0.5]) await backend.dispense(ops, [0], use_lld=True) """ assert len(ops) == len(use_channels) @@ -2715,18 +2824,75 @@ async def dispense( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) + n = len(ops) + if hamilton_liquid_classes is not None: + if len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = list(hamilton_liquid_classes) + else: + # Defaults from STAR calibration table; add get_prep_liquid_class if Prep needs different values. + hlcs = [ + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + for op in ops + ] + disable_volume_correction = disable_volume_correction if disable_volume_correction is not None else [False] * n + if len(disable_volume_correction) != n: + raise ValueError( + f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" + ) + ch_to_hlc = {ch: hlcs[i] for i, ch in enumerate(use_channels)} + ch_to_disable = {ch: disable_volume_correction[i] for i, ch in enumerate(use_channels)} + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + + # Default lists from HLC (fallbacks when HLC is None) + default_settling = [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] + default_transport_air_volume = [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs] + default_z_liquid_exit_speed = [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + default_stop_back_volume = [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs] + default_cutoff_speed = [hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs] + settling_time = fill_in_defaults(settling_time, default_settling) + transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) + z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) + stop_back_volume = fill_in_defaults(stop_back_volume, default_stop_back_volume) + cutoff_speed = fill_in_defaults(cutoff_speed, default_cutoff_speed) + + 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.0) + for op, hlc in zip(ops, hlcs) + ] + effective_lld = use_lld or (lld is not None) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - # Resolve z_final: when None, use bare-channel traverse height minus fitted tip length. - raw_traverse = self._resolve_traverse_height(final_z) - if final_z is None and indexed_ops: - max_fitted_length = max( - op.tip.total_tip_length - op.tip.fitting_depth for op in indexed_ops.values() - ) - resolved_z_final = raw_traverse - max_fitted_length - else: - resolved_z_final = raw_traverse + # Precompute well geometry once (used for default Z lists and for LLD in the loop). + well_geometry = [_absolute_z_from_well(op) for op in ops] + default_z_minimum = [g[0] for g in well_geometry] + default_z_fluid = [g[1] for g in well_geometry] + default_z_air = [g[3] for g in well_geometry] + raw_traverse = self._resolve_traverse_height(None) + default_final_z = [ + raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops + ] + default_z_bottom_search_offset = [2.0] * n + z_minimum = fill_in_defaults(z_minimum, default_z_minimum) + z_fluid = fill_in_defaults(z_fluid, default_z_fluid) + z_air = fill_in_defaults(z_air, default_z_air) + final_z = fill_in_defaults(final_z, default_final_z) + z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, default_z_bottom_search_offset) ch_segments: dict[int, list[SegmentDescriptor]] = {} for i, ch in enumerate(use_channels): @@ -2745,20 +2911,26 @@ async def dispense( for ch in range(self.num_channels): if ch not in indexed_ops: continue + idx = ch_to_idx[ch] op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") self._validate_position(loc.x, loc.y, loc.z) radius = _effective_radius(op.resource) - disp = DispenseParameters.for_op(loc) + disp = DispenseParameters.for_op( + loc, + stop_back_volume=stop_back_volume[idx], + cutoff_speed=cutoff_speed[idx], + ) segs = ch_segments[ch] - well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) - z_minimum_ch = z_minimum if z_minimum is not None else well_bottom_z - z_final_ch = resolved_z_final - z_fluid_ch = z_fluid if z_fluid is not None else liquid_surface_z - z_air_ch = z_air if z_air is not None else z_air_z + z_minimum_ch = z_minimum[idx] + z_final_ch = final_z[idx] + z_fluid_ch = z_fluid[idx] + z_air_ch = z_air[idx] + z_bottom_search_offset_ch = z_bottom_search_offset[idx] if effective_lld and lld is None: + top_of_well_z = well_geometry[idx][2] _lld = LldParameters( default_values=False, z_seek=top_of_well_z, @@ -2770,13 +2942,14 @@ async def dispense( _lld = lld or LldParameters.default() common = CommonParameters.for_op( - op.volume, radius, - flow_rate=op.flow_rate, + volumes[idx], + radius, + flow_rate=flow_rates[idx], z_minimum=z_minimum_ch, z_final=z_final_ch, - z_liquid_exit_speed=z_liquid_exit_speed, - transport_air_volume=transport_air_volume, - settling_time=settling_time, + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], ) if effective_lld: @@ -2799,7 +2972,7 @@ async def dispense( dispense=disp, container_description=segs, common=common, - no_lld=NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset), + no_lld=NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch), mix=MixParameters.default(), adc=AdcParameters.default(), tadm=TadmParameters.default(), @@ -2926,7 +3099,7 @@ async def move_to_position( x: float, y: Union[float, List[float]], z: Union[float, List[float]], - use_channels: Union[int, List[int]] = 0, + use_channels: Optional[Union[int, List[int]]] = 0, *, via_lane: bool = False, ) -> None: @@ -2936,7 +3109,13 @@ async def move_to_position( a list of indices; for all channels use list(range(self.num_channels)). For a single channel, y and z may be scalars instead of lists. """ - channels = [use_channels] if isinstance(use_channels, int) else list(use_channels) + if use_channels is None: + channels = [0] + elif isinstance(use_channels, list): + channels = list(use_channels) + else: + # int or int-like (e.g. numpy.int64); single channel + channels = [int(use_channels)] channels = sorted(channels) if channels: assert max(channels) < self.num_channels, ( From 835bb84ed948a9ad4a2c590b70a8947f994899fc Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:17:00 -0700 Subject: [PATCH 19/42] CORE Plate Movement --- .../backends/hamilton/prep_backend.py | 232 +++++++++++++++++- .../resources/hamilton/hamilton_decks.py | 31 ++- 2 files changed, 255 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 17d62253cd4..14478047b8f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -59,6 +59,7 @@ from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, + GripDirection, MultiHeadAspirationContainer, MultiHeadAspirationPlate, MultiHeadDispenseContainer, @@ -75,8 +76,8 @@ HamiltonLiquidClass, get_star_liquid_class, ) -from pylabrobot.resources import Tip -from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources import Coordinate, Tip +from pylabrobot.resources.hamilton import HamiltonCoreGrippers, HamiltonTip, TipSize from pylabrobot.resources.liquid import Liquid from pylabrobot.resources.tip_rack import TipSpot from pylabrobot.resources.trash import Trash @@ -1357,6 +1358,18 @@ class PrepReleasePlate(PrepCommand): command_id = 21 +# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. +CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( + default_values=False, + volume=1.0, + length=22.9, + tip_type=TipTypes.None_, + has_filter=False, + is_needle=False, + is_tool=True, +) + + @dataclass class PrepEmptyDispenser(PrepCommand): """Empty dispenser (cmd=23, dest=Pipettor).""" @@ -1905,6 +1918,7 @@ def __init__( self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) self._num_channels: Optional[int] = None self._has_mph: Optional[bool] = None + self._gripper_tool_on: bool = False def _has_interface(self, name: str) -> bool: """Return True if the interface was resolved and is present.""" @@ -2123,6 +2137,17 @@ def has_mph(self) -> bool: """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Set at setup from GetPresentChannels.""" return bool(self._has_mph) if self._has_mph is not None else False + @property + def num_arms(self) -> int: + """Number of resource-handling arms. 1 when deck has core_grippers and 2 channels, else 0.""" + if self.deck is None or self._num_channels is None or self._num_channels != 2: + return 0 + try: + mount = self.deck.get_resource("core_grippers") + return 1 if isinstance(mount, HamiltonCoreGrippers) else 0 + except Exception: + return 0 + def _validate_position(self, x: float, y: float, z: float) -> None: """Raise ValueError if (x, y, z) is outside deck bounds. No-op if config/bounds not set.""" # Validation disabled for now: deck bounds logic may not match actual deck coordinates @@ -3000,14 +3025,178 @@ async def dispense96( ): raise NotImplementedError("dispense96 is not supported on the Prep") - async def pick_up_resource(self, pickup: ResourcePickup): - raise NotImplementedError("pick_up_resource is not yet implemented on the Prep") + async def pick_up_tool( + self, + tool_position_x: float, + tool_position_z: float, + front_channel_position_y: float, + rear_channel_position_y: float, + *, + tool_seek: Optional[float] = None, + tool_x_radius: float = 2.0, + tool_y_radius: float = 2.0, + tip_definition: Optional[TipPickupParameters] = None, + ) -> None: + """Pick up tool from the given position (PrepPickUpTool, cmd=15). Sets _gripper_tool_on and moves channels to safe Z.""" + if tool_seek is None: + tool_seek = tool_position_z + 10.0 + if tip_definition is None: + tip_definition = CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + y_mid = (front_channel_position_y + rear_channel_position_y) / 2.0 + self._validate_position(tool_position_x, y_mid, tool_position_z) + await self.client.send_command( + PrepPickUpTool( + dest=await self._require("pipettor"), + tip_definition=tip_definition, + tool_position_x=tool_position_x, + tool_position_z=tool_position_z, + front_channel_position_y=front_channel_position_y, + rear_channel_position_y=rear_channel_position_y, + tool_seek=tool_seek, + tool_x_radius=tool_x_radius, + tool_y_radius=tool_y_radius, + ) + ) + self._gripper_tool_on = True + await self.move_channels_to_safe_z() + + async def drop_tool( + self, *, move_to_safe_z_first: bool = True + ) -> None: + """Drop tool (PrepDropTool, cmd=16). Optionally move channels to safe Z first. Clears _gripper_tool_on.""" + if move_to_safe_z_first: + await self.move_channels_to_safe_z() + await self.client.send_command( + PrepDropTool(dest=await self._require("pipettor")) + ) + self._gripper_tool_on = False + + async def release_plate(self) -> None: + """Release plate / open gripper (PrepReleasePlate, cmd=21). No parameters.""" + await self.client.send_command( + PrepReleasePlate(dest=await self._require("pipettor")) + ) + + async def pick_up_resource( + self, + pickup: ResourcePickup, + *, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ): + if self.deck is None: + raise RuntimeError("deck not set") + if pickup.direction != GripDirection.FRONT: + raise NotImplementedError( + "PREP CORE gripper only supports GripDirection.FRONT" + ) + resource = pickup.resource + center = resource.get_location_wrt(self.deck, "c", "c", "t") + pickup.offset + grip_height = center.z - pickup.pickup_distance_from_top + # plate_top_center = literal top center of plate (x, y, z_top); grip_height is separate. + plate_top_center = XYZCoord( + default_values=False, + x_position=center.x, + y_position=center.y, + z_position=center.z, + ) + # Grip distance = how far the grippers close from open (travel). Open = labware_y + clearance_y, final = labware_y - squeeze_mm → close by clearance_y + squeeze_mm. + grip_distance = clearance_y + squeeze_mm + plate_dims = PlateDimensions( + default_values=False, + length=resource.get_absolute_size_x(), + width=resource.get_absolute_size_y(), + height=resource.get_absolute_size_z(), + ) + if not self._gripper_tool_on: + mount = self.deck.get_resource("core_grippers") + if not isinstance(mount, HamiltonCoreGrippers): + raise TypeError( + "deck must have a resource named 'core_grippers' of type HamiltonCoreGrippers" + ) + loc = mount.get_location_wrt(self.deck) + await self.pick_up_tool( + tool_position_x=loc.x, + tool_position_z=loc.z, + front_channel_position_y=loc.y + mount.front_channel_y_center, + rear_channel_position_y=loc.y + mount.back_channel_y_center, + tool_seek=loc.z + 10.0, + ) + self._validate_position(center.x, center.y, grip_height) + await self.client.send_command( + PrepPickUpPlate( + dest=await self._require("pipettor"), + plate_top_center=plate_top_center, + plate=plate_dims, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + grip_distance=grip_distance, + grip_height=grip_height, + ) + ) async def move_picked_up_resource(self, move: ResourceMove): - raise NotImplementedError("move_picked_up_resource is not yet implemented on the Prep") + if self.deck is None: + raise RuntimeError("deck not set") + center = ( + move.location + + move.resource.get_anchor("c", "c", "t") + - Coordinate(z=move.pickup_distance_from_top) + + move.offset + ) + plate_top_center = XYZCoord( + default_values=False, + x_position=center.x, + y_position=center.y, + z_position=center.z, + ) + self._validate_position(center.x, center.y, center.z) + await self.client.send_command( + PrepMovePlate( + dest=await self._require("pipettor"), + plate_top_center=plate_top_center, + acceleration_scale_x=1, + ) + ) - async def drop_resource(self, drop: ResourceDrop): - raise NotImplementedError("drop_resource is not yet implemented on the Prep") + async def drop_resource( + self, + drop: ResourceDrop, + *, + return_gripper: bool = True, + clearance_y: float = 3.0, + ): + if self.deck is None: + raise RuntimeError("deck not set") + resource = drop.resource + dest_center = ( + drop.destination + + resource.get_anchor("c", "c", "t") + + drop.offset + ) + place_z = ( + drop.destination.z + + resource.get_absolute_size_z() + - drop.pickup_distance_from_top + ) + plate_top_center = XYZCoord( + default_values=False, + x_position=dest_center.x, + y_position=dest_center.y, + z_position=place_z, + ) + self._validate_position(dest_center.x, dest_center.y, place_z) + await self.client.send_command( + PrepDropPlate( + dest=await self._require("pipettor"), + plate_top_center=plate_top_center, + clearance_y=clearance_y, + acceleration_scale_x=1, + ) + ) + if return_gripper: + await self.drop_tool() def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: """Check if the tip can be picked up by the specified channel. @@ -3094,6 +3283,35 @@ async def disco_mode(self) -> None: # Pipettor convenience methods # --------------------------------------------------------------------------- + async def move_channels_to_safe_z( + self, channels: Optional[List[int]] = None + ) -> None: + """Move the given channels' Z axes up to safe (traverse) height (cmd=28). + + Use after picking up a tool or before returning a tool to avoid collisions + during XY moves. The instrument uses its configured safe/traverse height; + no height parameter is sent. + + Args: + channels: Channel indices to move (0=rear, 1=front). None = all channels. + """ + if channels is None: + channels = list(range(self.num_channels)) + else: + channels = sorted(set(channels)) + if not channels: + return + assert max(channels) < self.num_channels, ( + f"channel index out of range (valid: 0..{self.num_channels - 1})" + ) + channel_enums = [_CHANNEL_INDEX[ch] for ch in channels] + await self.client.send_command( + PrepMoveZUpToSafe( + dest=await self._require("pipettor"), + channels=channel_enums, + ) + ) + async def move_to_position( self, x: float, diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 050b46f31fd..76fd629db2f 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -389,6 +389,24 @@ def serialize(self): } +def prep_core_gripper_mount() -> HamiltonCoreGrippers: + """CORE gripper mount for PREP decks. Assign at Coordinate(290, 266.5, 62). + + Physical rear paddle at (290, 257.5, 62), front at (290, 275.5, 62). + front_channel_y_center / back_channel_y_center are named for the PREP command + (front_channel_position_y, rear_channel_position_y) so the correct paddle is used. + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=9.0, + front_channel_y_center=-9.0, + size_x=20.0, + size_y=20.0, + size_z=24.0, + model="prep_core_gripper_mount", + ) + + def hamilton_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: # inner hole diameter is 8.6mm # distance from base of rack to outer base of containers: -7mm @@ -629,11 +647,22 @@ class PrepDeck(Deck): """ def __init__( - self, name="deck", size_x=0, size_y=0.5, size_z=0, origin=Coordinate.zero(), category="deck" + self, + name="deck", + size_x=0, + size_y=0.5, + size_z=0, + origin=Coordinate.zero(), + category="deck", + with_core_grippers: bool = False, ): super().__init__( name=name, size_x=size_x, size_y=size_y, size_z=size_z, origin=origin, category=category ) + if with_core_grippers: + self.assign_child_resource( + prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5) + ) for column in range(2): for row in range(4): x = column * 140 From a77c0276bdf69ee4d0a265977f1952459e923b9e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:18:46 -0700 Subject: [PATCH 20/42] When instrument sends error messages, decode and display as part of resolution. --- .../backends/hamilton/tcp/introspection.py | 26 +++++++++++++++---- .../backends/hamilton/tcp/messages.py | 11 ++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 9cdbb9d9a5a..d75fdbc7430 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -100,6 +100,7 @@ def resolve_type_id(type_id: int) -> str: 53: "List[u32]", 57: "struct", # Complex type: (57, source_id, ref_id) → single struct 61: "List[struct]", # Complex type: (61, source_id, ref_id) → list of structs + 63: "struct", # Return element: struct ref in a return list (e.g. MoveYAbsolute return) 66: "List[bool]", 77: "List[str]", 82: "List[enum]", # Complex type, needs source_id + enum_id @@ -147,12 +148,12 @@ def resolve_type_id(type_id: int) -> str: # Type ID sets for categorization # 78 = enum (Argument); 60, 64 = struct (ReturnValue) — see _INTROSPECTION_TYPE_NAMES comments _ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 78, 82, 102} -_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} +_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 63, 68, 76} _RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 60, 64, 69, 81, 85, 104, 105} # Complex type sentinels: byte values that begin a 3-byte triple [type_id, source_id, ref_id]. # The two contexts (method parameterTypes vs struct structureElementTypes) use different sentinels. -_COMPLEX_METHOD_TYPE_IDS = {57, 60, 61, 64, 78, 81, 82, 85} # GetMethod parameterTypes triples +_COMPLEX_METHOD_TYPE_IDS = {57, 60, 61, 63, 64, 78, 81, 82, 85} # GetMethod parameterTypes triples _COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 # Backward-compat alias (used by ParameterType.is_complex for method parameters) _COMPLEX_TYPE_IDS = _COMPLEX_METHOD_TYPE_IDS @@ -247,7 +248,7 @@ def is_complex(self) -> bool: @property def is_struct_ref(self) -> bool: """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" - return self.type_id in {30, 31, 57, 60, 61, 64} + return self.type_id in {30, 31, 57, 60, 61, 63, 64} @property def is_enum_ref(self) -> bool: @@ -1378,11 +1379,25 @@ async def resolve_error( if hc_result is not None: lines.append(f" Device error: {describe_hc_result(hc_result)}") - elif error_text: + if error_text: lines.append(f" Device said: {error_text}") return "\n".join(lines) + @staticmethod + def parse_error_u8_array_message(error_string: str) -> Optional[str]: + """Extract the decoded U8_ARRAY device message from a Hamilton error string. + + The parsed error string may contain ``U8_ARRAY=...`` (or ``U8_ARRAY="..."``) + with the instrument's human-readable message. Returns that value or None. + """ + import re + # Match U8_ARRAY= then either quoted content or rest until "; " or " [" or end + m = re.search(r"U8_ARRAY=(?:\"([^\"]*)\"|([^;[\]]*?)(?:\s*;\s*|\s*\[|$))", error_string) + if not m: + return None + return (m.group(1) or m.group(2) or "").strip() or None + @staticmethod def parse_error_address( error_string: str, @@ -1446,9 +1461,10 @@ async def diagnose_error( if parsed is None: return f"Could not parse error address from: {error_string}" address, interface_id, method_id, hc_result = parsed + error_text = self.parse_error_u8_array_message(error_string) or "" return await self.resolve_error( address, interface_id, method_id, - registry=registry, hc_result=hc_result, + registry=registry, hc_result=hc_result, error_text=error_text, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index 0e82f2aa1e5..f1be0f8898c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -292,6 +292,17 @@ def _parse_hamilton_error_fragments(params: bytes) -> List[str]: type_name = f"type_{type_id}" if isinstance(decoded, bytes): decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00").strip() + elif ( + type_id == HamiltonDataType.U8_ARRAY + and isinstance(decoded, list) + and all(isinstance(x, int) and 0 <= x <= 255 for x in decoded) + ): + b = bytes(decoded) + s = b.decode("utf-8", errors="replace").rstrip("\x00").strip() + # Strip leading control characters (e.g. length or flags before message text) + s = s.lstrip("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f").strip() + if s and any(c.isprintable() or c.isspace() for c in s): + decoded = s out.append(f"{type_name}={decoded}") except Exception: out.append(f"type_{type_id}=<{length} bytes>") From 4e97d43029b733cb0f1f7e406c3ee95a737dbfba Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:30:40 -0700 Subject: [PATCH 21/42] 1. Separate prep command wire definitions from the prep_backend. Shorten overall file size. 2. Typing --- .../backends/hamilton/__init__.py | 2 +- .../backends/hamilton/prep_backend.py | 1883 +---------------- .../backends/hamilton/prep_commands.py | 1731 +++++++++++++++ .../backends/hamilton/tcp/introspection.py | 14 +- .../backends/hamilton/tcp/messages.py | 6 +- .../backends/hamilton/tcp/tcp_tests.py | 8 +- .../backends/hamilton/tcp/wire_types.py | 3 +- .../backends/hamilton/tcp_backend.py | 18 +- 8 files changed, 1828 insertions(+), 1837 deletions(-) create mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep_commands.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/__init__.py index c32450df3b5..f2dca3434d8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/backends/hamilton/__init__.py @@ -2,6 +2,6 @@ from .base import HamiltonLiquidHandler from .pump import Pump # TODO: move elsewhere. -from .prep import Prep +from .prep_backend import PrepBackend from .STAR_backend import STAR from .vantage_backend import Vantage diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 14478047b8f..229d1c062c6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -7,8 +7,8 @@ Address resolution: ``self.client.interfaces..address``. - **Command dataclasses** (e.g. ``PrepDropTips``, ``MphPickupTips``): Pure wire shapes. - ``@dataclass`` with ``dest: Address`` + ``Annotated`` payload fields; no defaults; - ``build_parameters()`` uses ``HoiParams.from_struct(self)``. + Defined in ``prep_commands.py``; ``@dataclass`` with ``dest: Address`` + + ``Annotated`` payload fields; ``build_parameters()`` uses ``HoiParams.from_struct(self)``. - **PrepBackend methods**: Domain logic and defaults. Single source of truth for Prep-specific parameter defaults. @@ -23,34 +23,12 @@ import logging import math import random -from dataclasses import dataclass -from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, List, Optional, Tuple, Union - -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol -from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - EnumArray, - F32, - I8, - I16, - I16Array, - I64, - PaddedBool, - PaddedU8, - Str, - Struct, - StructArray, - U16, - U32, - U8Array, - Enum as WEnum, -) +from typing import List, Optional, Tuple, Union + +from pylabrobot.liquid_handling.backends.hamilton.prep_commands import * # noqa: F401,F403 + from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults - from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( HamiltonTCPClient, HamiltonInterfaceResolver, @@ -83,824 +61,9 @@ from pylabrobot.resources.trash import Trash from pylabrobot.resources.well import CrossSectionType, Well -if TYPE_CHECKING: - pass - logger = logging.getLogger(__name__) -# ============================================================================= -# Enums (mirrored from Prep protocol spec, independent of prep.py) -# ============================================================================= - - -class ChannelIndex(IntEnum): - InvalidIndex = 0 - FrontChannel = 1 - RearChannel = 2 - MPHChannel = 3 - - -class TipDropType(IntEnum): - FixedHeight = 0 - Stall = 1 - CLLDSeek = 2 - - -class TipTypes(IntEnum): - None_ = 0 - LowVolume = 1 - StandardVolume = 2 - HighVolume = 3 - - -class TadmRecordingModes(IntEnum): - NoRecording = 0 - Errors = 1 - All = 2 - - -class MonitoringMode(IntEnum): - """Selects aspirate monitoring vs TADM for pipetting commands.""" - - MONITORING = 0 # AspirateMonitoringParameters (default, matches v1 behavior) - TADM = 1 # TadmParameters - - -# ============================================================================= -# Hardware config (probed from instrument, immutable) -# ============================================================================= - - -@dataclass(frozen=True) -class DeckBounds: - """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" - - min_x: float - max_x: float - min_y: float - max_y: float - min_z: float - max_z: float - - -@dataclass(frozen=True) -class DeckSiteInfo: - """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" - - id: int - left_bottom_front_x: float - left_bottom_front_y: float - left_bottom_front_z: float - length: float - width: float - height: float - - -@dataclass(frozen=True) -class WasteSiteInfo: - """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" - - index: int - x_position: float - y_position: float - z_position: float - z_seek: float - - -@dataclass(frozen=True) -class InstrumentConfig: - """Instrument hardware configuration probed at setup.""" - - deck_bounds: Optional[DeckBounds] - has_enclosure: bool - safe_speeds_enabled: bool - deck_sites: Tuple[DeckSiteInfo, ...] - waste_sites: Tuple[WasteSiteInfo, ...] - default_traverse_height: Optional[float] = None # None if probe failed; user can set via set_default_traverse_height - num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels - has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels - - -# ============================================================================= -# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) -# ============================================================================= - - -@dataclass -class SeekParameters: - x_start: F32 - y_start: F32 - z_start: F32 - distance: F32 - expected_position: F32 - - -@dataclass -class XYZCoord: - default_values: PaddedBool - x_position: F32 - y_position: F32 - z_position: F32 - - -@dataclass -class XYCoord: - default_values: PaddedBool - x_position: F32 - y_position: F32 - - -@dataclass -class ChannelYZMoveParameters: - default_values: PaddedBool - channel: WEnum - y_position: F32 - z_position: F32 - - -@dataclass -class GantryMoveXYZParameters: - default_values: PaddedBool - gantry_x_position: F32 - axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] - - -@dataclass -class PlateDimensions: - default_values: PaddedBool - length: F32 - width: F32 - height: F32 - - -@dataclass -class TipDefinition: - default_values: PaddedBool - id: PaddedU8 - volume: F32 - length: F32 - tip_type: WEnum - has_filter: PaddedBool - is_needle: PaddedBool - is_tool: PaddedBool - label: Str - - -@dataclass -class TipPickupParameters: - default_values: PaddedBool - volume: F32 - length: F32 - tip_type: WEnum - has_filter: PaddedBool - is_needle: PaddedBool - is_tool: PaddedBool - - -@dataclass -class AspirateParameters: - default_values: PaddedBool - x_position: F32 - y_position: F32 - prewet_volume: F32 - blowout_volume: F32 - - @classmethod - def for_op( - cls, - loc, - op: "SingleChannelAspiration", - prewet_volume: float = 0.0, - blowout_volume: Optional[float] = None, - ) -> "AspirateParameters": - return cls( - default_values=False, - x_position=loc.x, - y_position=loc.y, - prewet_volume=prewet_volume, - blowout_volume=(op.blow_out_air_volume or 0.0) if blowout_volume is None else blowout_volume, - ) - - -@dataclass -class DispenseParameters: - default_values: PaddedBool - x_position: F32 - y_position: F32 - stop_back_volume: F32 - cutoff_speed: F32 - - @classmethod - def for_op( - cls, - loc, - stop_back_volume: float = 0.0, - cutoff_speed: float = 100.0, - ) -> "DispenseParameters": - return cls( - default_values=False, - x_position=loc.x, - y_position=loc.y, - stop_back_volume=stop_back_volume, - cutoff_speed=cutoff_speed, - ) - - -@dataclass -class CommonParameters: - default_values: PaddedBool - empty: PaddedBool - z_minimum: F32 - z_final: F32 - z_liquid_exit_speed: F32 - liquid_volume: F32 - liquid_speed: F32 - transport_air_volume: F32 - tube_radius: F32 - cone_height: F32 - cone_bottom_radius: F32 - settling_time: F32 - additional_probes: U32 - - @classmethod - def for_op( - cls, - volume: float, - radius: float, - *, - flow_rate: Optional[float] = None, - empty: bool = True, - z_minimum: float = 5.0, - z_final: float = 96.97, - z_liquid_exit_speed: float = 10.0, - transport_air_volume: float = 0.0, - cone_height: float = 0.0, - cone_bottom_radius: float = 0.0, - settling_time: float = 1.0, - additional_probes: int = 0, - ) -> "CommonParameters": - """Build CommonParameters for a single aspirate/dispense op. - - z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has - its own size_z). High-level aspirate()/dispense() override with well bottom when None. - z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. - """ - return cls( - default_values=False, - empty=empty, - z_minimum=z_minimum, - z_final=z_final, - z_liquid_exit_speed=z_liquid_exit_speed, - liquid_volume=volume, - liquid_speed=flow_rate or 100.0, - transport_air_volume=transport_air_volume, - tube_radius=radius, - cone_height=cone_height, - cone_bottom_radius=cone_bottom_radius, - settling_time=settling_time, - additional_probes=additional_probes, - ) - - -@dataclass -class NoLldParameters: - default_values: PaddedBool - z_fluid: F32 - z_air: F32 - bottom_search: PaddedBool - z_bottom_search_offset: F32 - z_bottom_offset: F32 - - @classmethod - def for_fixed_z( - cls, - z_fluid: float = 94.97, - z_air: float = 96.97, - *, - z_bottom_search_offset: float = 2.0, - z_bottom_offset: float = 0.0, - ) -> "NoLldParameters": - return cls( - default_values=False, - z_fluid=z_fluid, - z_air=z_air, - bottom_search=False, - z_bottom_search_offset=z_bottom_search_offset, - z_bottom_offset=z_bottom_offset, - ) - - -@dataclass -class LldParameters: - default_values: PaddedBool - z_seek: F32 - z_seek_speed: F32 - z_submerge: F32 - z_out_of_liquid: F32 - - @classmethod - def default(cls) -> LldParameters: - return cls(default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0) - - -@dataclass -class CLldParameters: - default_values: PaddedBool - sensitivity: WEnum - clot_check_enable: PaddedBool - z_clot_check: F32 - detect_mode: WEnum - - @classmethod - def default(cls) -> CLldParameters: - return cls(default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0) - - -@dataclass -class PLldParameters: - default_values: PaddedBool - sensitivity: WEnum - dispenser_seek_speed: F32 - lld_height_difference: F32 - detect_mode: WEnum - - @classmethod - def default(cls) -> PLldParameters: - return cls(default_values=True, sensitivity=1, dispenser_seek_speed=0.0, lld_height_difference=0.0, detect_mode=0) - - -@dataclass -class TadmReturnParameters: - default_values: PaddedBool - channel: WEnum - entries: U32 - error: PaddedBool - data: I16Array - - -@dataclass -class TadmParameters: - default_values: PaddedBool - limit_curve_index: U16 - recording_mode: WEnum - - @classmethod - def default(cls) -> TadmParameters: - return cls( - default_values=True, - limit_curve_index=0, - recording_mode=TadmRecordingModes.Errors, - ) - - -@dataclass -class AspirateMonitoringParameters: - default_values: PaddedBool - c_lld_enable: PaddedBool - p_lld_enable: PaddedBool - minimum_differential: U16 - maximum_differential: U16 - clot_threshold: U16 - - @classmethod - def default(cls) -> AspirateMonitoringParameters: - return cls( - default_values=True, - c_lld_enable=False, - p_lld_enable=False, - minimum_differential=30, - maximum_differential=30, - clot_threshold=20, - ) - - -@dataclass -class MixParameters: - default_values: PaddedBool - z_offset: F32 - volume: F32 - cycles: PaddedU8 - speed: F32 - - @classmethod - def default(cls) -> MixParameters: - return cls( - default_values=True, - z_offset=0.0, - volume=0.0, - cycles=0, - speed=250.0, - ) - - -@dataclass -class AdcParameters: - default_values: PaddedBool - errors: PaddedBool - maximum_volume: F32 - - @classmethod - def default(cls) -> AdcParameters: - return cls( - default_values=True, - errors=True, - maximum_volume=4.5, - ) - - -@dataclass -class ChannelXYZPositionParameters: - default_values: PaddedBool - channel: WEnum - position_x: F32 - position_y: F32 - position_z: F32 - - -@dataclass -class PressureReturnParameters: - default_values: PaddedBool - channel: WEnum - pressure: U16 - - -@dataclass -class LiquidHeightReturnParameters: - default_values: PaddedBool - channel: WEnum - c_lld_detected: PaddedBool - c_lld_liquid_height: F32 - p_lld_detected: PaddedBool - p_lld_liquid_height: F32 - - -@dataclass -class DispenserVolumeReturnParameters: - default_values: PaddedBool - channel: WEnum - volume: F32 - - -@dataclass -class PotentiometerParameters: - default_values: PaddedBool - channel: WEnum - gain: PaddedU8 - offset: PaddedU8 - - -@dataclass -class YLLDSeekParameters: - default_values: PaddedBool - channel: WEnum - start_position_x: F32 - start_position_y: F32 - start_position_z: F32 - seek_position_y: F32 - seek_velocity_y: F32 - lld_sensitivity: WEnum - detect_mode: WEnum - - -@dataclass -class ChannelSeekParameters: - default_values: PaddedBool - channel: WEnum - seek_position_x: F32 - seek_position_y: F32 - seek_height: F32 - min_seek_height: F32 - final_position_z: F32 - - -@dataclass -class LLDChannelSeekParameters: - default_values: PaddedBool - channel: WEnum - seek_position_x: F32 - seek_position_y: F32 - seek_velocity_z: F32 - seek_height: F32 - min_seek_height: F32 - final_position_z: F32 - lld_sensitivity: WEnum - detect_mode: WEnum - - -@dataclass -class SeekResultParameters: - default_values: PaddedBool - channel: WEnum - detected: PaddedBool - position: F32 - - -@dataclass -class ChannelCounterParameters: - default_values: PaddedBool - channel: WEnum - tip_pickup_counter: U32 - tip_eject_counter: U32 - aspirate_counter: U32 - dispense_counter: U32 - - -@dataclass -class ChannelCalibrationParameters: - default_values: PaddedBool - channel: WEnum - dispenser_return_steps: U32 - squeeze_position: F32 - z_touchoff: F32 - z_tip_height: F32 - pressure_monitoring_shift: U32 - - -@dataclass -class LeakCheckSimpleParameters: - default_values: PaddedBool - channel: WEnum - time: F32 - high_pressure: PaddedBool - - -@dataclass -class LeakCheckParameters: - default_values: PaddedBool - channel: WEnum - start_position_x: F32 - start_position_y: F32 - start_position_z: F32 - seek_distance_y: F32 - pre_load_distance_y: F32 - final_z: F32 - tip_definition_id: PaddedU8 - test_time: F32 - high_pressure: PaddedBool - - -@dataclass -class DriveStatus: - initialized: PaddedBool - position: F32 - encoder_position: F32 - in_home_sensor: PaddedBool - - -@dataclass -class ChannelDriveStatus: - default_values: PaddedBool - channel: WEnum - y_axis_drive_status: Annotated[DriveStatus, Struct()] - z_axis_drive_status: Annotated[DriveStatus, Struct()] - dispenser_drive_status: Annotated[DriveStatus, Struct()] - squeeze_drive_status: Annotated[DriveStatus, Struct()] - - -@dataclass -class AspirateParametersNoLldAndMonitoring: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] - - -@dataclass -class AspirateParametersNoLldAndTadm: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -@dataclass -class AspirateParametersLldAndMonitoring: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - p_lld: Annotated[PLldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - - -@dataclass -class AspirateParametersLldAndTadm: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - p_lld: Annotated[PLldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - - -@dataclass -class DispenseParametersNoLld: - default_values: PaddedBool - channel: WEnum - dispense: Annotated[DispenseParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -@dataclass -class DispenseParametersLld: - default_values: PaddedBool - channel: WEnum - dispense: Annotated[DispenseParameters, Struct()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -@dataclass -class DropTipParameters: - default_values: PaddedBool - channel: WEnum - y_position: F32 - z_seek: F32 - z_tip: F32 - z_final: F32 - z_seek_speed: F32 - drop_type: WEnum - - -@dataclass -class InitTipDropParameters: - default_values: PaddedBool - x_position: F32 - rolloff_distance: F32 - channel_parameters: Annotated[list[DropTipParameters], StructArray()] - - -@dataclass -class DispenseInitToWasteParameters: - default_values: PaddedBool - channel: WEnum - x_position: F32 - y_position: F32 - z_position: F32 - - -@dataclass -class MoveAxisAbsoluteParameters: - default_values: PaddedBool - channel: WEnum - axis: WEnum - position: F32 - delay: U32 - - -@dataclass -class MoveAxisRelativeParameters: - default_values: PaddedBool - channel: WEnum - axis: WEnum - distance: F32 - delay: U32 - - -@dataclass -class LimitCurveEntry: - default_values: PaddedBool - sample: U16 - pressure: I16 - - -@dataclass -class TipPositionParameters: - default_values: PaddedBool - channel: WEnum - x_position: F32 - y_position: F32 - z_position: F32 - z_seek: F32 - - @classmethod - def for_op( - cls, - channel: WEnum, - loc, - tip, - *, - z_seek_offset: Optional[float] = None, - ) -> "TipPositionParameters": - """Build from an op location and tip (pickup). - - z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, - comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of - computed default (None = 0). - """ - z = loc.z + tip.total_tip_length - tip.fitting_depth - z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) - return cls( - default_values=False, - channel=channel, - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z_seek, - ) - - -@dataclass -class TipDropParameters: - default_values: PaddedBool - channel: WEnum - x_position: F32 - y_position: F32 - z_position: F32 - z_seek: F32 - drop_type: WEnum - - @classmethod - def for_op( - cls, - channel: WEnum, - loc, - tip, - *, - z_seek_offset: Optional[float] = None, - drop_type: Optional["TipDropType"] = None, - ) -> "TipDropParameters": - """Build from an op location and tip (drop). - - z_position uses (total_tip_length - fitting_depth) so the tip bottom lands - at the spot surface (consistent with STAR and with pickup). - z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during - lateral approach. z_seek_offset: additive mm on top of computed default - (None = 0). - """ - z = loc.z + (tip.total_tip_length - tip.fitting_depth) - z_seek = loc.z + tip.total_tip_length + 5.0 + (z_seek_offset or 0.0) - return cls( - default_values=False, - channel=channel, - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z_seek, - drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, - ) - - -@dataclass -class TipHeightCalibrationParameters: - default_values: PaddedBool - channel: WEnum - x_position: F32 - y_position: F32 - z_start: F32 - z_stop: F32 - z_final: F32 - volume: F32 - tip_type: WEnum - - -@dataclass -class DispenserVolumeEntry: - default_values: PaddedBool - type: WEnum - volume: F32 - - -@dataclass -class DispenserVolumeStackReturnParameters: - default_values: PaddedBool - channel: WEnum - total_volume: F32 - volumes: Annotated[list[DispenserVolumeEntry], StructArray()] - - -@dataclass -class SegmentDescriptor: - area_top: F32 - area_bottom: F32 - height: F32 - - def _effective_radius(resource) -> float: """Effective radius for CommonParameters.tube_radius. @@ -909,8 +72,8 @@ def _effective_radius(resource) -> float: firmware's conical liquid-following model. """ if isinstance(resource, Well) and resource.cross_section_type == CrossSectionType.RECTANGLE: - return math.sqrt(resource.get_size_x() * resource.get_size_y() / math.pi) - return resource.get_size_x() / 2 + return float(math.sqrt(resource.get_size_x() * resource.get_size_y() / math.pi)) + return float(resource.get_size_x() / 2) def _build_container_segments(resource) -> list[SegmentDescriptor]: @@ -981,885 +144,6 @@ def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): return (well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) -@dataclass -class AspirateParametersNoLldAndMonitoring2: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] - - -@dataclass -class AspirateParametersNoLldAndTadm2: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -@dataclass -class AspirateParametersLldAndMonitoring2: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - p_lld: Annotated[PLldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - - -@dataclass -class AspirateParametersLldAndTadm2: - default_values: PaddedBool - channel: WEnum - aspirate: Annotated[AspirateParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - p_lld: Annotated[PLldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - - -@dataclass -class DispenseParametersNoLld2: - default_values: PaddedBool - channel: WEnum - dispense: Annotated[DispenseParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - no_lld: Annotated[NoLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -@dataclass -class DispenseParametersLld2: - default_values: PaddedBool - channel: WEnum - dispense: Annotated[DispenseParameters, Struct()] - container_description: Annotated[list[SegmentDescriptor], StructArray()] - common: Annotated[CommonParameters, Struct()] - lld: Annotated[LldParameters, Struct()] - c_lld: Annotated[CLldParameters, Struct()] - mix: Annotated[MixParameters, Struct()] - adc: Annotated[AdcParameters, Struct()] - tadm: Annotated[TadmParameters, Struct()] - - -# ============================================================================= -# PrepCommand base class -# ============================================================================= - - -@dataclass -class PrepCommand(HamiltonCommand): - """Base for all Prep instrument commands. - - Subclasses are dataclasses with ``dest: Address`` (inherited) plus any - ``Annotated`` payload fields. ``build_parameters()`` calls - ``HoiParams.from_struct(self)`` which serialises only ``Annotated`` fields, - so ``dest`` is automatically excluded from the wire payload. - """ - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - - dest: Address - - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return HoiParams.from_struct(self) - - -# ============================================================================= -# Pipettor / ChannelCoordinator command classes -# ============================================================================= - - -@dataclass -class PrepAspirateNoLldMonitoring(PrepCommand): - """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" - - command_id = 1 - aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] - - -@dataclass -class PrepAspirateTadm(PrepCommand): - """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" - - command_id = 2 - aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] - - -@dataclass -class PrepAspirateWithLld(PrepCommand): - """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" - - command_id = 3 - aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] - - -@dataclass -class PrepAspirateWithLldTadm(PrepCommand): - """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" - - command_id = 4 - aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] - - -@dataclass -class PrepDispenseNoLld(PrepCommand): - """Dispense without LLD (cmd=5, dest=Pipettor).""" - - command_id = 5 - dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] - - -@dataclass -class PrepDispenseWithLld(PrepCommand): - """Dispense with LLD (cmd=6, dest=Pipettor).""" - - command_id = 6 - dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] - - -@dataclass -class PrepDispenseInitToWaste(PrepCommand): - """Dispense initialize to waste (cmd=7, dest=Pipettor).""" - - command_id = 7 - waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] - - -@dataclass -class PrepPickUpTipsById(PrepCommand): - """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" - - command_id = 8 - tip_positions: Annotated[list[TipPositionParameters], StructArray()] - final_z: F32 - seek_speed: F32 - tip_definition_id: PaddedU8 - enable_tadm: PaddedBool - dispenser_volume: F32 - dispenser_speed: F32 - - -@dataclass -class PrepPickUpTips(PrepCommand): - """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" - - command_id = 9 - tip_positions: Annotated[list[TipPositionParameters], StructArray()] - final_z: F32 - seek_speed: F32 - tip_definition: Annotated[TipPickupParameters, Struct()] - enable_tadm: PaddedBool - dispenser_volume: F32 - dispenser_speed: F32 - - -@dataclass -class PrepPickUpNeedlesById(PrepCommand): - """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" - - command_id = 10 - tip_positions: Annotated[list[TipPositionParameters], StructArray()] - final_z: F32 - seek_speed: F32 - tip_definition_id: PaddedU8 - blowout_offset: F32 - blowout_speed: F32 - enable_tadm: PaddedBool - dispenser_volume: F32 - dispenser_speed: F32 - - -@dataclass -class PrepPickUpNeedles(PrepCommand): - """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" - - command_id = 11 - tip_positions: Annotated[list[TipPositionParameters], StructArray()] - final_z: F32 - seek_speed: F32 - tip_definition: Annotated[TipPickupParameters, Struct()] - blowout_offset: F32 - blowout_speed: F32 - enable_tadm: PaddedBool - dispenser_volume: F32 - dispenser_speed: F32 - - -@dataclass -class PrepDropTips(PrepCommand): - """Drop tips (cmd=12, dest=Pipettor).""" - - command_id = 12 - tip_positions: Annotated[list[TipDropParameters], StructArray()] - final_z: F32 - seek_speed: F32 - tip_roll_off_distance: F32 - - -@dataclass -class MphPickupTips(PrepCommand): - """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). - - Resolved introspection signature: - PickupTips(tipParameters: struct(iface=1), finalZ: f32, - tipDefinition: struct(iface=1), tadm: bool, - dispenserVolume: f32, dispenserSpeed: f32, - tipMask: u32) -> { seekSpeed: List[u16] } - - The MPH takes a SINGLE struct (type_57) for tip_parameters, not a - StructArray (type_61) like the Pipettor. All 8 probes move as one unit; - tip_mask selects which channels engage. - """ - - command_id = 9 - tip_parameters: Annotated[TipPositionParameters, Struct()] - final_z: F32 - seek_speed: F32 - tip_definition: Annotated[TipPickupParameters, Struct()] - enable_tadm: PaddedBool - dispenser_volume: F32 - dispenser_speed: F32 - tip_mask: U32 - - -@dataclass -class MphDropTips(PrepCommand): - """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). - - Resolved introspection signature: - DropTips(dropTipParameters: struct(iface=1), finalZ: f32, - tipRollOffDistance: f32) -> seekSpeed: List[u16] - - Single struct (type_57) for drop position — all probes drop together. - """ - - command_id = 12 - drop_parameters: Annotated[TipDropParameters, Struct()] - final_z: F32 - seek_speed: F32 - tip_roll_off_distance: F32 - - -@dataclass -class PrepPickUpToolById(PrepCommand): - """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" - - command_id = 14 - tip_definition_id: PaddedU8 - tool_position_x: F32 - tool_position_z: F32 - front_channel_position_y: F32 - rear_channel_position_y: F32 - tool_seek: F32 - tool_x_radius: F32 - tool_y_radius: F32 - - -@dataclass -class PrepPickUpTool(PrepCommand): - """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" - - command_id = 15 - tip_definition: Annotated[TipPickupParameters, Struct()] - tool_position_x: F32 - tool_position_z: F32 - front_channel_position_y: F32 - rear_channel_position_y: F32 - tool_seek: F32 - tool_x_radius: F32 - tool_y_radius: F32 - - -@dataclass -class PrepDropTool(PrepCommand): - """Drop tool (cmd=16, dest=Pipettor).""" - - command_id = 16 - - -@dataclass -class PrepPickUpPlate(PrepCommand): - """Pick up plate (cmd=17, dest=Pipettor).""" - - command_id = 17 - plate_top_center: Annotated[XYZCoord, Struct()] - plate: Annotated[PlateDimensions, Struct()] - clearance_y: F32 - grip_speed_y: F32 - grip_distance: F32 - grip_height: F32 - - -@dataclass -class PrepDropPlate(PrepCommand): - """Drop plate (cmd=18, dest=Pipettor).""" - - command_id = 18 - plate_top_center: Annotated[XYZCoord, Struct()] - clearance_y: F32 - acceleration_scale_x: PaddedU8 - - -@dataclass -class PrepMovePlate(PrepCommand): - """Move plate to position (cmd=19, dest=Pipettor).""" - - command_id = 19 - plate_top_center: Annotated[XYZCoord, Struct()] - acceleration_scale_x: PaddedU8 - - -@dataclass -class PrepTransferPlate(PrepCommand): - """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" - - command_id = 20 - plate_source_top_center: Annotated[XYZCoord, Struct()] - plate_destination_top_center: Annotated[XYZCoord, Struct()] - plate: Annotated[PlateDimensions, Struct()] - clearance_y: F32 - grip_speed_y: F32 - grip_distance: F32 - grip_height: F32 - acceleration_scale_x: PaddedU8 - - -@dataclass -class PrepReleasePlate(PrepCommand): - """Release plate / open gripper (cmd=21, dest=Pipettor).""" - - command_id = 21 - - -# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. -CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( - default_values=False, - volume=1.0, - length=22.9, - tip_type=TipTypes.None_, - has_filter=False, - is_needle=False, - is_tool=True, -) - - -@dataclass -class PrepEmptyDispenser(PrepCommand): - """Empty dispenser (cmd=23, dest=Pipettor).""" - - command_id = 23 - channels: EnumArray - - -@dataclass -class PrepMoveToPosition(PrepCommand): - """Move to position (cmd=26, dest=Pipettor or ChannelCoordinator).""" - - command_id = 26 - move_parameters: Annotated[GantryMoveXYZParameters, Struct()] - - -@dataclass -class PrepMoveToPositionViaLane(PrepCommand): - """Move to position via lane (cmd=27, dest=Pipettor or ChannelCoordinator).""" - - command_id = 27 - move_parameters: Annotated[GantryMoveXYZParameters, Struct()] - - -@dataclass -class PrepMoveZUpToSafe(PrepCommand): - """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" - - command_id = 28 - channels: EnumArray - - -@dataclass -class PrepZSeekLldPosition(PrepCommand): - """Z-seek LLD position (cmd=29, dest=Pipettor).""" - - command_id = 29 - seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] - - -@dataclass -class PrepCreateTadmLimitCurve(PrepCommand): - """Create TADM limit curve (cmd=31, dest=Pipettor).""" - - command_id = 31 - channel: U32 - name: Str - lower_limit: Annotated[list[LimitCurveEntry], StructArray()] - upper_limit: Annotated[list[LimitCurveEntry], StructArray()] - - -@dataclass -class PrepEraseTadmLimitCurves(PrepCommand): - """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" - - command_id = 32 - channel: U32 - - -@dataclass -class PrepGetTadmLimitCurveNames(PrepCommand): - """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" - - command_id = 33 - channel: U32 - - -@dataclass -class PrepGetTadmLimitCurveInfo(PrepCommand): - """Get TADM limit curve info (cmd=34, dest=Pipettor).""" - - command_id = 34 - channel: U32 - name: Str - - -@dataclass -class PrepRetrieveTadmData(PrepCommand): - """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" - - command_id = 35 - channel: U32 - - -@dataclass -class PrepResetTadmFifo(PrepCommand): - """Reset TADM FIFO (cmd=36, dest=Pipettor).""" - - command_id = 36 - channels: EnumArray - - -@dataclass -class PrepAspirateNoLldMonitoringV2(PrepCommand): - """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" - - command_id = 38 - aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] - - -@dataclass -class PrepAspirateTadmV2(PrepCommand): - """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" - - command_id = 39 - aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] - - -@dataclass -class PrepAspirateWithLldV2(PrepCommand): - """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" - - command_id = 40 - aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] - - -@dataclass -class PrepAspirateWithLldTadmV2(PrepCommand): - """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" - - command_id = 41 - aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] - - -@dataclass -class PrepDispenseNoLldV2(PrepCommand): - """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" - - command_id = 42 - dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] - - -@dataclass -class PrepDispenseWithLldV2(PrepCommand): - """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" - - command_id = 43 - dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] - - -# ============================================================================= -# MLPrep command classes -# ============================================================================= - - -@dataclass -class PrepInitialize(PrepCommand): - """Initialize MLPrep (cmd=1, dest=MLPrep).""" - - command_id = 1 - smart: PaddedBool - tip_drop_params: Annotated[InitTipDropParameters, Struct()] - - -@dataclass -class PrepGetIsInitialized(PrepCommand): - """Query whether MLPrep is initialized. - - From introspection (MLPrepRoot.MLPrep): iface=1 id=2 GetIsInitialized(()) -> value: I64. - Sent as STATUS_REQUEST (0); response is STATUS_RESPONSE (1) with one I64. - """ - - command_id = 2 # GetIsInitialized per introspection_output/MLPrepRoot_MLPrep.txt - action_code = 0 # STATUS_REQUEST (query methods use 0, like Nimbus IsInitialized) - - @dataclass(frozen=True) - class Response: - value: I64 - - -@dataclass -class PrepPark(PrepCommand): - """Park MLPrep (cmd=3, dest=MLPrep).""" - - command_id = 3 - - -@dataclass -class PrepSpread(PrepCommand): - """Spread channels (cmd=4, dest=MLPrep).""" - - command_id = 4 - - -@dataclass -class PrepAddTipAndNeedleDefinition(PrepCommand): - """Add tip/needle definition (cmd=12, dest=MLPrep).""" - - command_id = 12 - tip_definition: Annotated[TipDefinition, Struct()] - - -@dataclass -class PrepRemoveTipAndNeedleDefinition(PrepCommand): - """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" - - command_id = 13 - id_: WEnum - - -@dataclass -class PrepReadStorage(PrepCommand): - """Read from instrument storage (cmd=14, dest=MLPrep).""" - - command_id = 14 - offset: U32 - length: U32 - - -@dataclass -class PrepWriteStorage(PrepCommand): - """Write to instrument storage (cmd=15, dest=MLPrep).""" - - command_id = 15 - offset: U32 - data: U8Array - - -@dataclass -class PrepPowerDownRequest(PrepCommand): - """Request power down (cmd=17, dest=MLPrep).""" - - command_id = 17 - - -@dataclass -class PrepConfirmPowerDown(PrepCommand): - """Confirm power down (cmd=18, dest=MLPrep).""" - - command_id = 18 - - -@dataclass -class PrepCancelPowerDown(PrepCommand): - """Cancel power down (cmd=19, dest=MLPrep).""" - - command_id = 19 - - -@dataclass -class PrepRemoveChannelPower(PrepCommand): - """Remove channel power for head swap (cmd=23, dest=MLPrep).""" - - command_id = 23 - - -@dataclass -class PrepRestoreChannelPower(PrepCommand): - """Restore channel power after head swap (cmd=24, dest=MLPrep).""" - - command_id = 24 - delay_ms: U32 - - -@dataclass -class PrepSetDeckLight(PrepCommand): - """Set deck LED colour (cmd=25, dest=MLPrep).""" - - command_id = 25 - white: PaddedU8 - red: PaddedU8 - green: PaddedU8 - blue: PaddedU8 - - -@dataclass -class PrepGetDeckLight(PrepCommand): - """Get deck LED colour (cmd=26, dest=MLPrep).""" - - command_id = 26 - action_code = 0 # STATUS_REQUEST - - @dataclass(frozen=True) - class Response: - white: PaddedU8 - red: PaddedU8 - green: PaddedU8 - blue: PaddedU8 - - -@dataclass -class PrepSuspendedPark(PrepCommand): - """Suspended park / move to load position (cmd=29, dest=MLPrep).""" - - command_id = 29 - move_parameters: Annotated[GantryMoveXYZParameters, Struct()] - - -@dataclass -class PrepMethodBegin(PrepCommand): - """Begin method (cmd=30, dest=MLPrep).""" - - command_id = 30 - automatic_pause: PaddedBool - - -@dataclass -class PrepMethodEnd(PrepCommand): - """End method (cmd=31, dest=MLPrep).""" - - command_id = 31 - - -@dataclass -class PrepMethodAbort(PrepCommand): - """Abort method (cmd=33, dest=MLPrep).""" - - command_id = 33 - - -@dataclass -class PrepIsParked(PrepCommand): - """Query parked status (cmd=34, dest=MLPrep). Introspection: IsParked(()) -> parked: I64.""" - - command_id = 34 - action_code = 0 # STATUS_REQUEST - - @dataclass(frozen=True) - class Response: - value: I64 - - -@dataclass -class PrepIsSpread(PrepCommand): - """Query spread status (cmd=35, dest=MLPrep). Introspection: IsSpread(()) -> parked: I64.""" - - command_id = 35 - action_code = 0 # STATUS_REQUEST - - @dataclass(frozen=True) - class Response: - value: I64 - - -# ----------------------------------------------------------------------------- -# Wire structs for config responses (used by nested Response and InstrumentConfig) -# ----------------------------------------------------------------------------- - - -@dataclass -class _DeckSiteDefinitionWire: - """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" - - default_values: PaddedBool - id: U32 - left_bottom_front_x: F32 - left_bottom_front_y: F32 - left_bottom_front_z: F32 - length: F32 - width: F32 - height: F32 - - -@dataclass -class _WasteSiteDefinitionWire: - """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" - - default_values: PaddedBool - index: WEnum - x_position: I8 - y_position: U16 - z_position: F32 - z_seek: F32 - - -# ----------------------------------------------------------------------------- -# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config -# ----------------------------------------------------------------------------- - - -@dataclass -class _PrepStatusQuery(PrepCommand): - """Base for MLPrep status queries: STATUS_REQUEST (0), no params.""" - - action_code = 0 - - -@dataclass -class PrepGetIsEnclosurePresent(_PrepStatusQuery): - """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Returns I64 as bool.""" - - command_id = 21 - - @dataclass(frozen=True) - class Response: - value: I64 - - -@dataclass -class PrepGetSafeSpeedsEnabled(_PrepStatusQuery): - """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Returns I64 as bool.""" - - command_id = 28 - - @dataclass(frozen=True) - class Response: - value: I64 - - -@dataclass -class PrepGetDefaultTraverseHeight(_PrepStatusQuery): - """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" - - command_id = 10 - - @dataclass(frozen=True) - class Response: - value: F32 - - -@dataclass -class PrepGetTipAndNeedleDefinitions(_PrepStatusQuery): - """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). - - Returns the list of tip/needle definitions registered on the instrument. - Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void - (response carries STRUCTURE_ARRAY of tip definition structs). - """ - - command_id = 11 - - @dataclass(frozen=True) - class Response: - definitions: Annotated[list[TipDefinition], StructArray()] - - -@dataclass -class PrepGetDeckBounds(_PrepStatusQuery): - """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" - - command_id = 1 - - @dataclass(frozen=True) - class Response: - min_x: F32 - max_x: F32 - min_y: F32 - max_y: F32 - min_z: F32 - max_z: F32 - - -@dataclass -class PrepGetDeckSiteDefinitions(_PrepStatusQuery): - """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). - - Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: - DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, - LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 - """ - - command_id = 7 - - @dataclass(frozen=True) - class Response: - sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] - - -@dataclass -class PrepGetWasteSiteDefinitions(_PrepStatusQuery): - """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). - - Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: - DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, - ZPosition: F32, ZSeek: F32 - """ - - command_id = 12 - - @dataclass(frozen=True) - class Response: - sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] - - -@dataclass -class PrepGetPresentChannels(_PrepStatusQuery): - """GetPresentChannels (cmd=17, dest=MLPrepService). - - Returns a list of enum values (iface=1, id=5): which channels are present. - Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. - Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. - """ - - command_id = 17 - - @dataclass(frozen=True) - class Response: - channels: EnumArray # list of ints: map to ChannelIndex for present channels - - # ============================================================================= # PrepBackend # ============================================================================= @@ -1869,7 +153,6 @@ class Response: 1: ChannelIndex.FrontChannel, } - # Expected root name from discovery; validated at setup(). _EXPECTED_ROOT = "MLPrepRoot" @@ -2022,7 +305,6 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self._config.has_mph, ) - # await self.ensure_spread() self.setup_finished = True async def _run_initialize(self, smart: bool): @@ -2148,21 +430,6 @@ def num_arms(self) -> int: except Exception: return 0 - def _validate_position(self, x: float, y: float, z: float) -> None: - """Raise ValueError if (x, y, z) is outside deck bounds. No-op if config/bounds not set.""" - # Validation disabled for now: deck bounds logic may not match actual deck coordinates - # (e.g. well cavity_bottom Z can be below reported min_z). The instrument will reject - # invalid positions. - return - # if self._config is None or self._config.deck_bounds is None: - # return - # b = self._config.deck_bounds - # if not (b.min_x <= x <= b.max_x and b.min_y <= y <= b.max_y and b.min_z <= z <= b.max_z): - # raise ValueError( - # f"Position ({x}, {y}, {z}) outside deck bounds " - # f"(x=[{b.min_x}, {b.max_x}], y=[{b.min_y}, {b.max_y}], z=[{b.min_z}, {b.max_z}])" - # ) - def _resolve_traverse_height(self, final_z: Optional[float]) -> float: """Resolve final_z: explicit arg > user-set default > probed value. Raises if none available.""" if final_z is not None: @@ -2264,7 +531,6 @@ async def pick_up_tips( _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), z_seek_offset=z_seek_offset, ) - self._validate_position(loc.x, loc.y, params.z_position) tip_positions.append(params) assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip type" @@ -2359,7 +625,6 @@ async def drop_tips( z_seek_offset=z_seek_offset, drop_type=drop_type, ) - self._validate_position(loc.x, loc.y, params.z_position) tip_positions.append(params) await self.client.send_command( @@ -2428,7 +693,6 @@ async def pick_up_tips_mph( tip_parameters = TipPositionParameters.for_op( ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset ) - self._validate_position(loc.x, loc.y, tip_parameters.z_position) tip_definition = TipPickupParameters( default_values=False, @@ -2498,7 +762,6 @@ async def drop_tips_mph( z_seek_offset=z_seek_offset, drop_type=drop_type, ) - self._validate_position(loc.x, loc.y, drop_parameters.z_position) await self.client.send_command( MphDropTips( @@ -2514,15 +777,15 @@ async def aspirate( self, ops: List[SingleChannelAspiration], use_channels: List[int], - z_final: Optional[List[Optional[float]]] = None, - z_fluid: Optional[List[Optional[float]]] = None, - z_air: Optional[List[Optional[float]]] = None, - settling_time: Optional[List[Optional[float]]] = None, - transport_air_volume: Optional[List[Optional[float]]] = None, - z_liquid_exit_speed: Optional[List[Optional[float]]] = None, - prewet_volume: Optional[List[Optional[float]]] = None, - z_minimum: Optional[List[Optional[float]]] = None, - z_bottom_search_offset: Optional[List[Optional[float]]] = None, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + prewet_volume: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, monitoring_mode: MonitoringMode = MonitoringMode.MONITORING, use_lld: bool = False, lld: Optional[LldParameters] = None, @@ -2531,33 +794,33 @@ async def aspirate( tadm: Optional[TadmParameters] = None, container_segments: Optional[List[List[SegmentDescriptor]]] = None, # TODO: Doesn't work with No LLD auto_container_geometry: bool = False, - hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, disable_volume_correction: Optional[List[bool]] = None, ): """Aspirate using v2 commands, dispatching to the appropriate variant. Selects the command variant based on ``use_lld`` / ``lld`` (LLD on/off) and ``monitoring_mode`` (Monitoring vs TADM). Z/geometry parameters (z_final, - z_fluid, z_air, z_minimum, z_bottom_search_offset): when None, default lists - are derived from well geometry (absolute coordinates, STAR-aligned). When a - list is passed, length must match len(ops); each element overrides that channel - (list entry None = use default for that channel). + z_fluid, z_air, z_minimum, z_bottom_search_offset): None = use defaults for all + channels (derived from well geometry, STAR-aligned). Otherwise pass a list of + length len(ops) with one value per channel (no None in list). For per-channel + defaults, build the list from liquid class or constants. Liquid-class-derived parameters (settling_time, transport_air_volume, - z_liquid_exit_speed, prewet_volume): when None, HLC (or fallback) is used per channel. - When a list is passed, length must match len(ops); each element overrides the HLC for that - channel (list entry None = use HLC/fallback for that channel). + z_liquid_exit_speed, prewet_volume): None = use defaults for all channels (HLC + or fallback per channel). Otherwise pass a list of length len(ops) with one + value per channel (no None in list). Args: - z_final: Z after the move (retract height) per channel. None = traverse minus fitted tip length per op. - z_fluid: Liquid surface Z when not using LLD, per channel. None = well_bottom + op.liquid_height. - z_air: Z in air (above liquid), per channel. None = top of well + 2 mm. - settling_time: Settling time (s) per channel. None = liquid class or fallback 1.0 per channel. - transport_air_volume: Transport air volume (µL) per channel. None = liquid class or fallback 0.0. - z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = liquid class or fallback 10.0. - prewet_volume: Pre-wet volume (µL) per channel. None = liquid class or fallback 0.0. - z_minimum: Minimum Z (well floor) per channel. None = well bottom. - z_bottom_search_offset: Bottom search offset (mm) per channel. None = 2.0 per channel. + z_final: Z after the move (retract height) per channel. None = defaults for all; else list of len(ops), no None in list. + z_fluid: Liquid surface Z when not using LLD, per channel. None = defaults for all; else list of len(ops). + z_air: Z in air (above liquid), per channel. None = defaults for all; else list of len(ops). + settling_time: Settling time (s) per channel. None = defaults for all; else list of len(ops). + transport_air_volume: Transport air volume (µL) per channel. None = defaults for all; else list of len(ops). + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = defaults for all; else list of len(ops). + prewet_volume: Pre-wet volume (µL) per channel. None = defaults for all; else list of len(ops). + z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). + z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). monitoring_mode: Select TADM or Monitoring (default: Monitoring). use_lld: Enable LLD aspirate variant. Also activated if ``lld`` is set. lld: LLD seek parameters. When None and use_lld=True, built from labware geometry @@ -2570,9 +833,8 @@ async def aspirate( auto_container_geometry: Automatically build container segments from the well's cross-section geometry. Pass False to use empty segments (firmware falls back to the CommonParameters cone model). - hamilton_liquid_classes: Optional list of Hamilton liquid classes, one per op. - When None, defaults are built per op via get_star_liquid_class (same as STAR). - When provided, length must match len(ops). + hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). + Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. Example:: @@ -2588,6 +850,7 @@ async def aspirate( ) n = len(ops) + hlcs: List[Optional[HamiltonLiquidClass]] if hamilton_liquid_classes is not None: if len(hamilton_liquid_classes) != n: raise ValueError( @@ -2613,8 +876,6 @@ async def aspirate( raise ValueError( f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" ) - ch_to_hlc = {ch: hlcs[i] for i, ch in enumerate(use_channels)} - ch_to_disable = {ch: disable_volume_correction[i] for i, ch in enumerate(use_channels)} ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} # Default lists from HLC (fallbacks when HLC is None) @@ -2684,7 +945,6 @@ async def aspirate( idx = ch_to_idx[ch] op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") - self._validate_position(loc.x, loc.y, loc.z) radius = _effective_radius(op.resource) asp = AspirateParameters.for_op( loc, @@ -2787,55 +1047,54 @@ async def dispense( self, ops: List[SingleChannelDispense], use_channels: List[int], - final_z: Optional[List[Optional[float]]] = None, - z_fluid: Optional[List[Optional[float]]] = None, - z_air: Optional[List[Optional[float]]] = None, - settling_time: Optional[List[Optional[float]]] = None, - transport_air_volume: Optional[List[Optional[float]]] = None, - z_liquid_exit_speed: Optional[List[Optional[float]]] = None, - stop_back_volume: Optional[List[Optional[float]]] = None, - cutoff_speed: Optional[List[Optional[float]]] = None, - z_minimum: Optional[List[Optional[float]]] = None, - z_bottom_search_offset: Optional[List[Optional[float]]] = None, + final_z: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + cutoff_speed: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, use_lld: bool = False, lld: Optional[LldParameters] = None, c_lld: Optional[CLldParameters] = None, container_segments: Optional[List[List[SegmentDescriptor]]] = None, auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD - hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, disable_volume_correction: Optional[List[bool]] = None, ): """Dispense using v2 commands, dispatching to NoLLD or LLD variant. Z/geometry parameters (final_z, z_fluid, z_air, z_minimum, z_bottom_search_offset): - when None, default lists are derived from well geometry (absolute coordinates, - STAR-aligned). When a list is passed, length must match len(ops); each element - overrides that channel (list entry None = use default for that channel). + None = use defaults for all channels (derived from well geometry, STAR-aligned). + Otherwise pass a list of length len(ops) with one value per channel (no None in list). + For per-channel defaults, build the list from liquid class or constants. Liquid-class-derived parameters (settling_time, transport_air_volume, - z_liquid_exit_speed, stop_back_volume, cutoff_speed): when None, HLC (or fallback) - is used per channel. When a list is passed, length must match len(ops); each - element overrides the HLC for that channel (list entry None = use HLC/fallback). + z_liquid_exit_speed, stop_back_volume, cutoff_speed): None = use defaults for all + channels (HLC or fallback per channel). Otherwise pass a list of length len(ops) + with one value per channel (no None in list). Args: - final_z: Z after the move per channel. None = traverse minus fitted tip length per op. - z_fluid: Liquid surface Z when not using LLD, per channel. None = well_bottom + op.liquid_height. - z_air: Z in air (above liquid), per channel. None = top of well + 2 mm. - settling_time: Settling time (s) per channel. None = liquid class or fallback 0.0 per channel. - transport_air_volume: Transport air volume (µL) per channel. None = liquid class or fallback 0.0. - z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = liquid class or fallback 10.0. - stop_back_volume: Stop-back volume (µL) per channel. None = liquid class or fallback 0.0. - cutoff_speed: Cutoff/stop flow rate (µL/s) per channel. None = liquid class or fallback 100.0. - z_minimum: Minimum Z (well floor) per channel. None = well bottom. - z_bottom_search_offset: Bottom search offset (mm) per channel. None = 2.0 per channel. + final_z: Z after the move per channel. None = defaults for all; else list of len(ops), no None in list. + z_fluid: Liquid surface Z when not using LLD, per channel. None = defaults for all; else list of len(ops). + z_air: Z in air (above liquid), per channel. None = defaults for all; else list of len(ops). + settling_time: Settling time (s) per channel. None = defaults for all; else list of len(ops). + transport_air_volume: Transport air volume (µL) per channel. None = defaults for all; else list of len(ops). + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = defaults for all; else list of len(ops). + stop_back_volume: Stop-back volume (µL) per channel. None = defaults for all; else list of len(ops). + cutoff_speed: Cutoff/stop flow rate (µL/s) per channel. None = defaults for all; else list of len(ops). + z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). + z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). use_lld: Enable LLD dispense variant. Also activated if ``lld`` is set. lld: LLD seek parameters. When None and use_lld=True, built from labware geometry. c_lld: Capacitive LLD parameters (LLD variant only). container_segments: Per-channel SegmentDescriptor lists for liquid following. auto_container_geometry: Automatically build container segments from well geometry. - hamilton_liquid_classes: Optional list of Hamilton liquid classes, one per op. - When None, defaults are built per op via get_star_liquid_class (same as STAR). - When provided, length must match len(ops). + hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). + Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. Example:: @@ -2850,6 +1109,7 @@ async def dispense( ) n = len(ops) + hlcs: List[Optional[HamiltonLiquidClass]] if hamilton_liquid_classes is not None: if len(hamilton_liquid_classes) != n: raise ValueError( @@ -2875,8 +1135,6 @@ async def dispense( raise ValueError( f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" ) - ch_to_hlc = {ch: hlcs[i] for i, ch in enumerate(use_channels)} - ch_to_disable = {ch: disable_volume_correction[i] for i, ch in enumerate(use_channels)} ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} # Default lists from HLC (fallbacks when HLC is None) @@ -2939,7 +1197,6 @@ async def dispense( idx = ch_to_idx[ch] op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") - self._validate_position(loc.x, loc.y, loc.z) radius = _effective_radius(op.resource) disp = DispenseParameters.for_op( loc, @@ -3042,8 +1299,6 @@ async def pick_up_tool( tool_seek = tool_position_z + 10.0 if tip_definition is None: tip_definition = CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS - y_mid = (front_channel_position_y + rear_channel_position_y) / 2.0 - self._validate_position(tool_position_x, y_mid, tool_position_z) await self.client.send_command( PrepPickUpTool( dest=await self._require("pipettor"), @@ -3123,7 +1378,6 @@ async def pick_up_resource( rear_channel_position_y=loc.y + mount.back_channel_y_center, tool_seek=loc.z + 10.0, ) - self._validate_position(center.x, center.y, grip_height) await self.client.send_command( PrepPickUpPlate( dest=await self._require("pipettor"), @@ -3151,7 +1405,6 @@ async def move_picked_up_resource(self, move: ResourceMove): y_position=center.y, z_position=center.z, ) - self._validate_position(center.x, center.y, center.z) await self.client.send_command( PrepMovePlate( dest=await self._require("pipettor"), @@ -3186,7 +1439,6 @@ async def drop_resource( y_position=dest_center.y, z_position=place_z, ) - self._validate_position(dest_center.x, dest_center.y, place_z) await self.client.send_command( PrepDropPlate( dest=await self._require("pipettor"), @@ -3348,7 +1600,6 @@ async def move_to_position( for i, ch in enumerate(channels): y_i = y if isinstance(y, (int, float)) else y[i] z_i = z if isinstance(z, (int, float)) else z[i] - self._validate_position(x, y_i, z_i) axis_parameters.append( ChannelYZMoveParameters( default_values=False, diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py new file mode 100644 index 00000000000..b7a37c7861f --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -0,0 +1,1731 @@ +"""Prep command dataclasses and wire-type parameter structs. + +Pure data definitions for the Hamilton Prep protocol — enums, hardware config, +wire-type annotated parameter structs, and PrepCommand subclasses. No business +logic; used by PrepBackend for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Annotated, Optional, Tuple + +from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.standard import SingleChannelAspiration +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + EnumArray, + F32, + I8, + I16, + I16Array, + I64, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U16, + U32, + U8Array, + Enum as WEnum, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import InterfaceSpec + + +# ============================================================================= +# Enums (mirrored from Prep protocol spec) +# ============================================================================= + + +class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + +class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + +class TipTypes(IntEnum): + None_ = 0 + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + +class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + +class MonitoringMode(IntEnum): + """Selects aspirate monitoring vs TADM for pipetting commands.""" + + MONITORING = 0 # AspirateMonitoringParameters (default, matches v1 behavior) + TADM = 1 # TadmParameters + + +# ============================================================================= +# Hardware config (probed from instrument, immutable) +# ============================================================================= + + +@dataclass(frozen=True) +class DeckBounds: + """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" + + min_x: float + max_x: float + min_y: float + max_y: float + min_z: float + max_z: float + + +@dataclass(frozen=True) +class DeckSiteInfo: + """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + + +@dataclass(frozen=True) +class WasteSiteInfo: + """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" + + index: int + x_position: float + y_position: float + z_position: float + z_seek: float + + +@dataclass(frozen=True) +class InstrumentConfig: + """Instrument hardware configuration probed at setup.""" + + deck_bounds: Optional[DeckBounds] + has_enclosure: bool + safe_speeds_enabled: bool + deck_sites: Tuple[DeckSiteInfo, ...] + waste_sites: Tuple[WasteSiteInfo, ...] + default_traverse_height: Optional[float] = None # None if probe failed; user can set via set_default_traverse_height + num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels + has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels + + +# ============================================================================= +# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) +# ============================================================================= + + +@dataclass +class SeekParameters: + x_start: F32 + y_start: F32 + z_start: F32 + distance: F32 + expected_position: F32 + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + height: F32 + + +@dataclass +class TipDefinition: + default_values: PaddedBool + id: PaddedU8 + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + label: Str + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def for_op( + cls, + loc, + op: SingleChannelAspiration, + prewet_volume: float = 0.0, + blowout_volume: Optional[float] = None, + ) -> AspirateParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=(op.blow_out_air_volume or 0.0) if blowout_volume is None else blowout_volume, + ) + + +@dataclass +class DispenseParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + stop_back_volume: F32 + cutoff_speed: F32 + + @classmethod + def for_op( + cls, + loc, + stop_back_volume: float = 0.0, + cutoff_speed: float = 100.0, + ) -> DispenseParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + + +@dataclass +class CommonParameters: + default_values: PaddedBool + empty: PaddedBool + z_minimum: F32 + z_final: F32 + z_liquid_exit_speed: F32 + liquid_volume: F32 + liquid_speed: F32 + transport_air_volume: F32 + tube_radius: F32 + cone_height: F32 + cone_bottom_radius: F32 + settling_time: F32 + additional_probes: U32 + + @classmethod + def for_op( + cls, + volume: float, + radius: float, + *, + flow_rate: Optional[float] = None, + empty: bool = True, + z_minimum: float = 5.0, + z_final: float = 96.97, + z_liquid_exit_speed: float = 10.0, + transport_air_volume: float = 0.0, + cone_height: float = 0.0, + cone_bottom_radius: float = 0.0, + settling_time: float = 1.0, + additional_probes: int = 0, + ) -> CommonParameters: + """Build CommonParameters for a single aspirate/dispense op. + + z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has + its own size_z). High-level aspirate()/dispense() override with well bottom when None. + z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. + """ + return cls( + default_values=False, + empty=empty, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + liquid_volume=volume, + liquid_speed=flow_rate or 100.0, + transport_air_volume=transport_air_volume, + tube_radius=radius, + cone_height=cone_height, + cone_bottom_radius=cone_bottom_radius, + settling_time=settling_time, + additional_probes=additional_probes, + ) + + +@dataclass +class NoLldParameters: + default_values: PaddedBool + z_fluid: F32 + z_air: F32 + bottom_search: PaddedBool + z_bottom_search_offset: F32 + z_bottom_offset: F32 + + @classmethod + def for_fixed_z( + cls, + z_fluid: float = 94.97, + z_air: float = 96.97, + *, + z_bottom_search_offset: float = 2.0, + z_bottom_offset: float = 0.0, + ) -> NoLldParameters: + return cls( + default_values=False, + z_fluid=z_fluid, + z_air=z_air, + bottom_search=False, + z_bottom_search_offset=z_bottom_search_offset, + z_bottom_offset=z_bottom_offset, + ) + + +@dataclass +class LldParameters: + default_values: PaddedBool + z_seek: F32 + z_seek_speed: F32 + z_submerge: F32 + z_out_of_liquid: F32 + + @classmethod + def default(cls) -> LldParameters: + return cls(default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0) + + +@dataclass +class CLldParameters: + default_values: PaddedBool + sensitivity: WEnum + clot_check_enable: PaddedBool + z_clot_check: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> CLldParameters: + return cls(default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0) + + +@dataclass +class PLldParameters: + default_values: PaddedBool + sensitivity: WEnum + dispenser_seek_speed: F32 + lld_height_difference: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> PLldParameters: + return cls(default_values=True, sensitivity=1, dispenser_seek_speed=0.0, lld_height_difference=0.0, detect_mode=0) + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + data: I16Array + + +@dataclass +class TadmParameters: + default_values: PaddedBool + limit_curve_index: U16 + recording_mode: WEnum + + @classmethod + def default(cls) -> TadmParameters: + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=TadmRecordingModes.Errors, + ) + + +@dataclass +class AspirateMonitoringParameters: + default_values: PaddedBool + c_lld_enable: PaddedBool + p_lld_enable: PaddedBool + minimum_differential: U16 + maximum_differential: U16 + clot_threshold: U16 + + @classmethod + def default(cls) -> AspirateMonitoringParameters: + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + +@dataclass +class MixParameters: + default_values: PaddedBool + z_offset: F32 + volume: F32 + cycles: PaddedU8 + speed: F32 + + @classmethod + def default(cls) -> MixParameters: + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + +@dataclass +class AdcParameters: + default_values: PaddedBool + errors: PaddedBool + maximum_volume: F32 + + @classmethod + def default(cls) -> AdcParameters: + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + pressure: U16 + + +@dataclass +class LiquidHeightReturnParameters: + default_values: PaddedBool + channel: WEnum + c_lld_detected: PaddedBool + c_lld_liquid_height: F32 + p_lld_detected: PaddedBool + p_lld_liquid_height: F32 + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + offset: PaddedU8 + + +@dataclass +class YLLDSeekParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_position_y: F32 + seek_velocity_y: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class ChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + + +@dataclass +class LLDChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_velocity_z: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + dispense_counter: U32 + + +@dataclass +class ChannelCalibrationParameters: + default_values: PaddedBool + channel: WEnum + dispenser_return_steps: U32 + squeeze_position: F32 + z_touchoff: F32 + z_tip_height: F32 + pressure_monitoring_shift: U32 + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + high_pressure: PaddedBool + + +@dataclass +class LeakCheckParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_distance_y: F32 + pre_load_distance_y: F32 + final_z: F32 + tip_definition_id: PaddedU8 + test_time: F32 + high_pressure: PaddedBool + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + in_home_sensor: PaddedBool + + +@dataclass +class ChannelDriveStatus: + default_values: PaddedBool + channel: WEnum + y_axis_drive_status: Annotated[DriveStatus, Struct()] + z_axis_drive_status: Annotated[DriveStatus, Struct()] + dispenser_drive_status: Annotated[DriveStatus, Struct()] + squeeze_drive_status: Annotated[DriveStatus, Struct()] + + +@dataclass +class AspirateParametersNoLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DropTipParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_seek: F32 + z_tip: F32 + z_final: F32 + z_seek_speed: F32 + drop_type: WEnum + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + pressure: I16 + + +@dataclass +class TipPositionParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + ) -> TipPositionParameters: + """Build from an op location and tip (pickup). + + z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, + comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of + computed default (None = 0). + """ + z = loc.z + tip.total_tip_length - tip.fitting_depth + z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + ) + + +@dataclass +class TipDropParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + drop_type: WEnum + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + drop_type: Optional[TipDropType] = None, + ) -> TipDropParameters: + """Build from an op location and tip (drop). + + z_position uses (total_tip_length - fitting_depth) so the tip bottom lands + at the spot surface (consistent with STAR and with pickup). + z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during + lateral approach. z_seek_offset: additive mm on top of computed default + (None = 0). + """ + z = loc.z + (tip.total_tip_length - tip.fitting_depth) + z_seek = loc.z + tip.total_tip_length + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, + ) + + +@dataclass +class TipHeightCalibrationParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + z_final: F32 + volume: F32 + tip_type: WEnum + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + height: F32 + + +@dataclass +class AspirateParametersNoLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +@dataclass +class PrepCommand(HamiltonCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with ``dest: Address`` (inherited) plus any + ``Annotated`` payload fields. ``build_parameters()`` calls + ``HoiParams.from_struct(self)`` which serialises only ``Annotated`` fields, + so ``dest`` is automatically excluded from the wire payload. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpTips(PrepCommand): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedlesById(PrepCommand): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedles(PrepCommand): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepDropTips(PrepCommand): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphPickupTips(PrepCommand): + """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). + + Resolved introspection signature: + PickupTips(tipParameters: struct(iface=1), finalZ: f32, + tipDefinition: struct(iface=1), tadm: bool, + dispenserVolume: f32, dispenserSpeed: f32, + tipMask: u32) -> { seekSpeed: List[u16] } + + The MPH takes a SINGLE struct (type_57) for tip_parameters, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + tip_parameters: Annotated[TipPositionParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + tip_mask: U32 + + +@dataclass +class MphDropTips(PrepCommand): + """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). + + Resolved introspection signature: + DropTips(dropTipParameters: struct(iface=1), finalZ: f32, + tipRollOffDistance: f32) -> seekSpeed: List[u16] + + Single struct (type_57) for drop position — all probes drop together. + """ + + command_id = 12 + drop_parameters: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + tip_definition_id: PaddedU8 + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepPickUpTool(PrepCommand): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + tip_definition: Annotated[TipPickupParameters, Struct()] + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepDropTool(PrepCommand): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + +@dataclass +class PrepDropPlate(PrepCommand): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepMovePlate(PrepCommand): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepTransferPlate(PrepCommand): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + plate_source_top_center: Annotated[XYZCoord, Struct()] + plate_destination_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepReleasePlate(PrepCommand): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + + +# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. +CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( + default_values=False, + volume=1.0, + length=22.9, + tip_type=TipTypes.None_, + has_filter=False, + is_needle=False, + is_tool=True, +) + + +@dataclass +class PrepEmptyDispenser(PrepCommand): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move to position (cmd=26, dest=Pipettor or ChannelCoordinator).""" + + command_id = 26 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move to position via lane (cmd=27, dest=Pipettor or ChannelCoordinator).""" + + command_id = 27 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveZUpToSafe(PrepCommand): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + +@dataclass +class PrepEraseTadmLimitCurves(PrepCommand): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepCommand): + """Query whether MLPrep is initialized. + + From introspection (MLPrepRoot.MLPrep): iface=1 id=2 GetIsInitialized(()) -> value: I64. + Sent as STATUS_REQUEST (0); response is STATUS_RESPONSE (1) with one I64. + """ + + command_id = 2 # GetIsInitialized per introspection_output/MLPrepRoot_MLPrep.txt + action_code = 0 # STATUS_REQUEST (query methods use 0, like Nimbus IsInitialized) + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepCommand): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepSuspendedPark(PrepCommand): + """Suspended park / move to load position (cmd=29, dest=MLPrep).""" + + command_id = 29 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + + +@dataclass +class PrepIsParked(PrepCommand): + """Query parked status (cmd=34, dest=MLPrep). Introspection: IsParked(()) -> parked: I64.""" + + command_id = 34 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepIsSpread(PrepCommand): + """Query spread status (cmd=35, dest=MLPrep). Introspection: IsSpread(()) -> parked: I64.""" + + command_id = 35 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 + + +# ----------------------------------------------------------------------------- +# Wire structs for config responses (used by nested Response and InstrumentConfig) +# ----------------------------------------------------------------------------- + + +@dataclass +class _DeckSiteDefinitionWire: + """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + + +@dataclass +class _WasteSiteDefinitionWire: + """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" + + default_values: PaddedBool + index: WEnum + x_position: I8 + y_position: U16 + z_position: F32 + z_seek: F32 + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config +# ----------------------------------------------------------------------------- + + +@dataclass +class _PrepStatusQuery(PrepCommand): + """Base for MLPrep status queries: STATUS_REQUEST (0), no params.""" + + action_code = 0 + + +@dataclass +class PrepGetIsEnclosurePresent(_PrepStatusQuery): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Returns I64 as bool.""" + + command_id = 21 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetSafeSpeedsEnabled(_PrepStatusQuery): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Returns I64 as bool.""" + + command_id = 28 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetDefaultTraverseHeight(_PrepStatusQuery): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(_PrepStatusQuery): + """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). + + Returns the list of tip/needle definitions registered on the instrument. + Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void + (response carries STRUCTURE_ARRAY of tip definition structs). + """ + + command_id = 11 + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(_PrepStatusQuery): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + +@dataclass +class PrepGetDeckSiteDefinitions(_PrepStatusQuery): + """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, + LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 + """ + + command_id = 7 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(_PrepStatusQuery): + """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: + DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, + ZPosition: F32, ZSeek: F32 + """ + + command_id = 12 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetPresentChannels(_PrepStatusQuery): + """GetPresentChannels (cmd=17, dest=MLPrepService). + + Returns a list of enum values (iface=1, id=5): which channels are present. + Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. + Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. + """ + + command_id = 17 + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index d75fdbc7430..f0f3ca4ce17 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -24,7 +24,7 @@ import logging from dataclasses import dataclass, field -from typing import Dict, List, Optional, Set, Union +from typing import Dict, List, Optional, Set, Union, cast from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( @@ -419,7 +419,7 @@ class TypeRegistry: print(method.get_signature_string(registry)) # PickupTips(tipParameters: PickupTipParameters, ...) """ - address: Address + address: Optional[Address] = None interfaces: Dict[int, "InterfaceInfo"] = field(default_factory=dict) structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) @@ -857,7 +857,7 @@ def __init__(self, backend): def _resolve_address(self, addr_or_path: Union[Address, str]) -> Address: """Resolve dot-path string to Address using the backend's registry, or return Address as-is.""" if isinstance(addr_or_path, str): - return self.backend._registry.address(addr_or_path) + return cast(Address, self.backend._registry.address(addr_or_path)) return addr_or_path async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: @@ -1495,7 +1495,7 @@ def _get_wire_type_id(annotation) -> Optional[int]: if metadata: for m in metadata: if hasattr(m, "type_id"): - return m.type_id + return cast(int, m.type_id) return None @@ -1729,13 +1729,15 @@ def validate_command( ] for (pf, annotation), pt in zip(struct_fields, struct_params): + ref_id = pt.ref_id + assert ref_id is not None, "struct_params filtered for ref_id is not None" if pt.source_id == 1: - intro_struct = pool.resolve_struct(pt.ref_id) + intro_struct = pool.resolve_struct(ref_id) elif pt.source_id == 0: # Same-interface ref: would need interface_id context; skip for now intro_struct = None else: - intro_struct = pool.resolve_struct(pt.ref_id) + intro_struct = pool.resolve_struct(ref_id) nested_cls = _get_nested_dataclass(annotation) if intro_struct and nested_cls: child_result = validate_struct(nested_cls, intro_struct, pool) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index f1be0f8898c..a2daea8201b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import dataclass, fields as dc_fields -from typing import Any, List, get_args, get_origin, get_type_hints +from typing import Any, List, cast, get_args, get_origin, get_type_hints from pylabrobot.io.binary import Reader, Writer from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( @@ -96,7 +96,7 @@ def add(self, value: Any, wire_type: Any) -> "HoiParams": """ if hasattr(wire_type, "__metadata__"): wire_type = wire_type.__metadata__[0] - return wire_type.encode_into(value, self) + return cast("HoiParams", wire_type.encode_into(value, self)) # ------------------------------------------------------------------ # Generic dataclass serialiser (wire_types.py Annotated metadata) @@ -125,7 +125,7 @@ def from_struct(cls, obj) -> "HoiParams": if not isinstance(meta, WireType): continue params = meta.encode_into(getattr(obj, f.name), params) - return params + return cast("HoiParams", params) def build(self) -> bytes: """Return concatenated DataFragments.""" diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 023a4c55bf6..03b2f3ff6dd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -1194,10 +1194,12 @@ def test_roundtrip_i32(self): self._roundtrip_scalar(I32, 42, HamiltonDataType.I32) def test_roundtrip_f32(self): + from typing import get_args + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import F32 value = 2.5 # exactly representable in float32 - meta = F32.__metadata__[0] + meta = get_args(F32)[1] params = HoiParams() meta.encode_into(value, params) frag = params._fragments[0] @@ -1216,9 +1218,11 @@ def test_roundtrip_i16_array(self): self._roundtrip_array(I16Array, [1, 2, 3], HamiltonDataType.I16_ARRAY) def test_roundtrip_string(self): + from typing import get_args + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import Str - meta = Str.__metadata__[0] + meta = get_args(Str)[1] params = HoiParams() meta.encode_into("hello", params) frag = params._fragments[0] diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py index 060da3234e9..1bfc243042d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/wire_types.py @@ -312,7 +312,8 @@ def decode_from(self, data: bytes) -> Any: def _register(alias: type) -> None: - meta = alias.__metadata__[0] + meta = getattr(alias, "__metadata__", (None,))[0] + assert meta is not None, f"Expected Annotated alias with metadata: {alias}" _WIRE_TYPE_REGISTRY[meta.type_id] = meta diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 1f0f6fad9d8..339847baedb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -35,7 +35,7 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, cast from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket @@ -214,7 +214,7 @@ def address(self) -> Address: """Wire-level destination for this path. Use for required interfaces; KeyError if not discovered.""" path = object.__getattribute__(self, "_path") registry = object.__getattribute__(self, "_registry") - return registry.address(path) + return cast(Address, registry.address(path)) @property def info(self) -> ObjectInfo: @@ -223,7 +223,7 @@ def info(self) -> ObjectInfo: obj = registry._objects.get(path) if obj is None: raise KeyError(f"'{path}' not in registry. Call await .resolve() first.") - return obj + return cast(ObjectInfo, obj) @property def is_available(self) -> bool: @@ -301,10 +301,12 @@ async def require(self, name: str) -> Address: if spec.raise_when_missing: logger.warning("%s", msg) raise RuntimeError(msg) from None - return self._resolved[name] + addr = self._resolved[name] + assert addr is not None + return addr try: await self.client.interfaces[spec.path].resolve() - addr = self.client.interfaces[spec.path].address + addr = cast(Address, self.client.interfaces[spec.path].address) self._resolved[name] = addr logger.debug("Resolved %s → %s (%s)", name, addr, spec.path) return addr @@ -324,9 +326,9 @@ async def run_setup_loop(self) -> None: addr = await self.require(name) logger.info("Found interface '%s' (%s) at %s", name, spec.path, addr) else: - addr = await self.get(name) - if addr is not None: - logger.info("Found interface '%s' (%s) at %s", name, spec.path, addr) + optional_addr = await self.get(name) + if optional_addr is not None: + logger.info("Found interface '%s' (%s) at %s", name, spec.path, optional_addr) else: logger.info("Could not find interface '%s' (%s) on instrument.", name, spec.path) From 2ef589b23231497a9e8ae10ffd75c2542fd529a0 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:04:28 -0700 Subject: [PATCH 22/42] Refine Prep Deck based on Teaching Needle Probe --- pylabrobot/resources/hamilton/hamilton_decks.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 76fd629db2f..99d31a55c9c 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -2,7 +2,7 @@ import logging from abc import ABCMeta, abstractmethod -from typing import Literal, Optional, cast +from typing import List, Literal, Optional, cast from pylabrobot.resources.carrier import ResourceHolder from pylabrobot.resources.coordinate import Coordinate @@ -649,8 +649,8 @@ class PrepDeck(Deck): def __init__( self, name="deck", - size_x=0, - size_y=0.5, + size_x=300.0, + size_y=320.0, size_z=0, origin=Coordinate.zero(), category="deck", @@ -663,10 +663,11 @@ def __init__( self.assign_child_resource( prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5) ) + spots_list: List[ResourceHolder] = [] for column in range(2): for row in range(4): x = column * 140 - y = row * 95.5 # ? + y = (row * 95) + 2 # TODO: Check if this offset is valid on other systems (plastic corner stay?) spot = ResourceHolder( name=f"spot_{column}_{row}", size_x=127.76, @@ -675,6 +676,8 @@ def __init__( child_location=Coordinate(0, 0, 3.75), ) self.assign_child_resource(spot, location=Coordinate(x, y, 0)) + spots_list.append(spot) + self.spots: List[ResourceHolder] = spots_list trash = Trash(name="trash", size_x=0, size_y=0, size_z=0) # TODO: y coordinate @@ -713,7 +716,9 @@ def __init__( ) def __getitem__(self, key: int) -> ResourceHolder: - return self.children[key] + """Get labware spot by index 0-7 (column-major: 0=spot_0_0, ..., 7=spot_1_3).""" + return self.spots[key] def __setitem__(self, key: int, value: Resource): - self.children[key].assign_child_resource(value) + """Assign resource to labware spot by index 0-7.""" + self.spots[key].assign_child_resource(value) From 67e11c87a5b34463047db85344dc8a14284d6991 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:45:31 -0700 Subject: [PATCH 23/42] 1. TCPBackend as dependency injection. 2. INFO -> DEBUG levels for logs 3. Refine Prep Deck, add roll off discard_tips logic 4. Add Demo notebook --- .../prep_demo_teaching_liquid_gripper.ipynb | 438 ++++++++++++++++++ .../backends/hamilton/nimbus_backend.py | 46 +- .../backends/hamilton/nimbus_backend_tests.py | 8 + .../backends/hamilton/prep_backend.py | 123 +++-- .../backends/hamilton/prep_commands.py | 2 +- .../backends/hamilton/tcp_backend.py | 107 +++-- .../resources/hamilton/hamilton_decks.py | 18 +- 7 files changed, 640 insertions(+), 102 deletions(-) create mode 100644 docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb new file mode 100644 index 00000000000..57810b7b93f --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb @@ -0,0 +1,438 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hamilton PREP: Concise demo — teaching needle, liquid transfer, plate movement\n", + "\n", + "Single notebook demonstrating:\n", + "1. **Teaching needle** — Pick up teaching tip, move above plate A1 at safe height, drop tip.\n", + "2. **Liquid handling** — Tip pickup, dual-channel aspirate and dispense.\n", + "3. **Plate movement** — CORE gripper: pick plate from deck[0], drop at deck[1].\n", + "\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[0] (moved to deck[1]). Visualizer runs after deck creation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Imports and config" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8165c4f9", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import logging\n", + "from asyncio import sleep\n", + "\n", + "from pylabrobot.liquid_handling.backends.hamilton.prep_backend import PrepBackend\n", + "from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient\n", + "from pylabrobot.liquid_handling import LiquidHandler\n", + "from pylabrobot.resources import Coordinate\n", + "from pylabrobot.resources.hamilton import PrepDeck\n", + "from pylabrobot.resources import hamilton_96_tiprack_50uL_NTR, Cor_Axy_96_wellplate_500uL_Ub\n", + "from pylabrobot.visualizer import Visualizer\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n", + "logging.getLogger(\"pylabrobot\").handlers.clear()\n", + "handler = logging.StreamHandler(sys.stdout)\n", + "handler.setFormatter(logging.Formatter(\"%(levelname)s - %(message)s\"))\n", + "logging.getLogger(\"pylabrobot\").addHandler(handler)\n", + "\n", + "HOST = \"192.168.100.102\"\n", + "PORT = 2000\n", + "SAFE_HEIGHT_MM_ABOVE_WELL = 25" + ] + }, + { + "cell_type": "markdown", + "id": "a5fdf5b0", + "metadata": {}, + "source": [ + "## 2. Deck layout and visualizer" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c02643fb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Websocket server started at http://127.0.0.1:2122\n", + "File server started at http://127.0.0.1:1338 . Open this URL in your browser.\n" + ] + } + ], + "source": [ + "# PrepDeck: spots 0–7 (column-major). With CORE grippers for plate movement.\n", + "deck = PrepDeck(with_core_grippers=True)\n", + "\n", + "tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name=\"ntr_50\", with_tips=True)\n", + "plate = deck[0] = Cor_Axy_96_wellplate_500uL_Ub(\"plate\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "6ed4c83f", + "metadata": {}, + "source": [ + "## 3. Backend and liquid handler setup" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b457df04", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Connection initialized (Client ID: 35, Address: 2:35:65535)\n", + "INFO - Client registered.\n", + "INFO - Setup complete. Registered as Client ID 35 (2:35:65535), Root: MLPrepRoot\n", + "WARNING - Unknown introspection type category for type_id=113; treating as parameter\n", + "INFO - Interfaces: coordinator, deck_config, mlprep, mlprep_service, mph, pipettor\n", + "INFO - MLPrep already initialized, skipping Initialize\n", + "INFO - Hardware config: has_enclosure=True, safe_speeds=False, traverse_height=167.5, deck_bounds=DeckBounds(min_x=0.0, max_x=299.0, min_y=-9.0, max_y=385.0, min_z=19.5, max_z=167.5), deck_sites=6, waste_sites=3, num_channels=2, has_mph=True\n" + ] + } + ], + "source": [ + "backend = PrepBackend(host=HOST, port=PORT)\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "await lh.setup(smart=True, force_initialize=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9c4f5771", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Instrument configuration:\n", + " Deck bounds: DeckBounds(min_x=0.0, max_x=299.0, min_y=-9.0, max_y=385.0, min_z=19.5, max_z=167.5)\n", + " Has enclosure: True\n", + " Safe speeds enabled: False\n", + " Default traverse height: 167.5\n", + " Number of channels: 2\n", + " Has MPH: True\n", + "\n", + "Deck sites:\n", + "DeckSiteInfo(id=0, left_bottom_front_x=-2.2300000190734863, left_bottom_front_y=390.0, left_bottom_front_z=0.0, length=310.0, width=100.0, height=167.0)\n", + "DeckSiteInfo(id=1, left_bottom_front_x=270.0, left_bottom_front_y=-3.0, left_bottom_front_z=0.0, length=100.0, width=400.0, height=73.0)\n", + "DeckSiteInfo(id=2, left_bottom_front_x=284.760009765625, left_bottom_front_y=214.2899932861328, left_bottom_front_z=0.0, length=6.0, width=6.0, height=85.0)\n", + "DeckSiteInfo(id=0, left_bottom_front_x=-2.2300000190734863, left_bottom_front_y=390.0, left_bottom_front_z=0.0, length=310.0, width=100.0, height=165.60000610351562)\n", + "DeckSiteInfo(id=1, left_bottom_front_x=270.0, left_bottom_front_y=-3.0, left_bottom_front_z=0.0, length=100.0, width=400.0, height=73.0)\n", + "DeckSiteInfo(id=2, left_bottom_front_x=284.760009765625, left_bottom_front_y=214.2899932861328, left_bottom_front_z=0.0, length=6.0, width=6.0, height=85.0)\n", + "\n", + "Waste sites:\n", + "WasteSiteInfo(index=1, x_position=286.79998779296875, y_position=10.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n", + "WasteSiteInfo(index=2, x_position=286.79998779296875, y_position=30.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n", + "WasteSiteInfo(index=3, x_position=286.79998779296875, y_position=112.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n" + ] + } + ], + "source": [ + "# Deck, waste, and other config data from setup can be accessed here.\n", + "\n", + "config = lh.backend._config\n", + "print(\"Instrument configuration:\")\n", + "print(f\" Deck bounds: {config.deck_bounds}\")\n", + "print(f\" Has enclosure: {config.has_enclosure}\")\n", + "print(f\" Safe speeds enabled: {config.safe_speeds_enabled}\")\n", + "print(f\" Default traverse height: {config.default_traverse_height}\")\n", + "print(f\" Number of channels: {config.num_channels}\")\n", + "print(f\" Has MPH: {config.has_mph}\")\n", + "\n", + "print(\"\\nDeck sites:\")\n", + "for site in config.deck_sites:\n", + " print(site)\n", + "\n", + "print(\"\\nWaste sites:\")\n", + "for waste in config.waste_sites:\n", + " print(waste)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "711d509a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Global type pool built: 57 structs, 11 enums from 1 global objects\n", + "Command signatures on MLPrepRoot.PipettorRoot.Pipettor (49 methods):\n", + "\n", + " [1:1] Aspirate(aspirateParameters: AspirateParametersNoLldAndMonitoring) -> void\n", + " [1:2] AspirateTadm(aspirateParameters: AspirateParametersNoLldAndTadm) -> void\n", + " [1:3] AspirateLld(aspirateParameters: AspirateParametersLldAndMonitoring) -> void\n", + " [1:4] AspirateLldTadm(aspirateParameters: AspirateParametersLldAndTadm) -> void\n", + " [1:5] Dispense(dispenseParameters: DispenseParametersNoLld) -> void\n", + " [1:6] DispenseLld(dispenseParameters: DispenseParametersLld) -> void\n", + " [1:7] DispenseInitializeToWaste(wasteParameters: DispenseInitToWasteParameters) -> void\n", + " [1:8] PickupTips(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinitionId: u8, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:9] PickupTips(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinition: TipPickupParameters, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:10] PickupNeedles(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinitionId: u8, blowoutOffset: f32, blowoutSpeed: f32, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:11] PickupNeedles(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinition: TipPickupParameters, blowoutOffset: f32, blowoutSpeed: f32, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:12] DropTips(tipParameters: TipDropParameters, finalZ: f32, seekSpeed: f32, tipRollOffDistance: f32) -> void\n", + " [1:13] GetTipDefinitionHeld(void) -> value: TipDefinition\n", + " [1:14] PickupTool(tipDefinitionId: u8, toolPositionX: f32, toolPositionZ: f32, frontChannelPositionY: f32, rearChannelPositionY: f32, toolSeek: f32, toolXRadius: f32, toolYRadius: f32) -> void\n", + " [1:15] PickupTool(tipDefinition: TipPickupParameters, toolPositionX: f32, toolPositionZ: f32, frontChannelPositionY: f32, rearChannelPositionY: f32, toolSeek: f32, toolXRadius: f32, toolYRadius: f32) -> void\n", + " [1:16] DropTool(void) -> void\n", + " [1:17] PickupPlate(plateTopCenter: XYZCoord, plate: PlateDimensions, clearanceY: f32, gripSpeedY: f32, gripDistance: f32, gripHeight: f32) -> void\n", + " [1:18] DropPlate(plateTopCenter: XYZCoord, clearanceY: f32, accelerationScaleX: u8) -> void\n", + " [1:19] MovePlate(plateTopCenter: XYZCoord, accelerationScaleX: u8) -> void\n", + " [1:20] TransferPlate(plateSourceTopCenter: XYZCoord, plateDestinationTopCenter: XYZCoord, plate: PlateDimensions, clearanceY: f32, gripSpeedY: f32, gripDistance: f32, gripHeight: f32, accelerationScaleX: u8) -> void\n", + " [1:21] ReleasePlate(void) -> void\n", + " [1:22] GetPlateHeld(void) -> value: bool\n", + " [1:23] EmptyDispenser(channels: ChannelIndex) -> void\n", + " [1:24] GetCurrentDispenserVolume(void) -> value: DispenserVolumeReturnParameters\n", + " [1:25] GetPositions(void) -> value: ChannelXYZPositionParameters\n", + " [1:26] MoveToPosition(moveParameters: GantryMoveXYZParameters) -> void\n", + " [1:27] MoveToPositionViaLane(moveParameters: GantryMoveXYZParameters) -> void\n", + " [1:28] MoveZUpToSafe(channels: ChannelIndex) -> void\n", + " [1:29] ZSeekLldPosition(seekParameters: LLDChannelSeekParameters) -> results: SeekResultParameters\n", + " [1:30] GetLiquidHeight(void) -> value: LiquidHeightReturnParameters\n", + " [1:31] CreateTadmLimitCurve(channel: ChannelIndex, name: str, lowerLimit: LimitCurveEntry, upperLimit: LimitCurveEntry) -> index: u16\n", + " [1:32] EraseTadmLimitCurves(channel: ChannelIndex) -> void\n", + " [1:33] GetTadmLimitCurveNames(channel: ChannelIndex, names: List[str]) -> void\n", + " [1:34] GetTadmLimitCurveInfo(channel: ChannelIndex, name: str) -> { index: u16, lowerLimits: u16, upperLimits: u16 }\n", + " [1:35] RetrieveTadmData(channel: ChannelIndex) -> tadmData: TadmReturnParameters\n", + " [1:36] ResetTadmFifo(channels: ChannelIndex) -> void\n", + " [1:37] GetDispenserVolumeStack(void) -> value: DispenserVolumeStackReturnParameters\n", + " [1:38] Aspirate(aspirateParameters: AspirateParametersNoLldAndMonitoring2) -> void\n", + " [1:39] AspirateTadm(aspirateParameters: AspirateParametersNoLldAndTadm2) -> void\n", + " [1:40] AspirateLld(aspirateParameters: AspirateParametersLldAndMonitoring2) -> void\n", + " [1:41] AspirateLldTadm(aspirateParameters: AspirateParametersLldAndTadm2) -> void\n", + " [1:42] Dispense(dispenseParameters: DispenseParametersNoLld2) -> void\n", + " [1:43] DispenseLld(dispenseParameters: DispenseParametersLld2) -> void\n", + " [0:1] ObjectInfo(void) -> { name: str, version: str, methods: u32, subobjects: u16 }\n", + " [0:2] MethodInfo(method: u32) -> { interfaceid: u8, action: u8, actionid: u16, name: str, parametertypes: str, parameternames: str }\n", + " [0:3] SubObjectInfo(subobject: u16) -> { moduleID: u16, nodeID: u16, objectID: u16 }\n", + " [0:4] InterfaceDescriptors(void) -> { interfaceIds: bytes, interfaceDescriptors: List[str] }\n", + " [0:5] EnumInfo(interfaceId: u8) -> { enumerationNames: List[str], numberEnumerationValues: List[u32], enumerationValues: List[i32], enumerationValueDescriptions: List[str] }\n", + " [0:6] StructInfo(interfaceId: u8) -> { structNames: List[str], numberStructureElements: List[u32], structureElementTypes: bytes, structureElementDescriptions: List[str] }\n" + ] + } + ], + "source": [ + "# INTROSPECTION: PREP pipette interface: list command signatures via backend.client.introspect()\n", + "PIPETTOR_PATH = \"MLPrepRoot.PipettorRoot.Pipettor\"\n", + "pool, reg = await backend.client.introspect(PIPETTOR_PATH)\n", + "\n", + "print(f\"Command signatures on {PIPETTOR_PATH} ({len(reg.methods)} methods):\\n\")\n", + "for m in reg.methods:\n", + " print(f\" {m.get_signature_string(reg)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8fc3d808", + "metadata": {}, + "source": [ + "## 4. Teaching needle: above plate A1 at safe height" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "70f1360b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_tips(tip_spots=['teaching_tip'], use_channels=None, offsets=None)\n", + "DEBUG - PrepPickUpTips parameters: {'tip_positions': [TipPositionParameters(default_values=False, channel=, x_position=287.76, y_position=217.29, z_position=75.75, z_seek=88.75)], 'final_z': 167.5, 'seek_speed': 15.0, 'tip_definition': TipPickupParameters(default_values=False, volume=360, length=51.9, tip_type=, has_filter=True, is_needle=False, is_tool=False), 'enable_tadm': False, 'dispenser_volume': 0.0, 'dispenser_speed': 250.0}\n", + "DEBUG - PrepMoveToPosition parameters: {'move_parameters': GantryMoveXYZParameters(default_values=False, gantry_x_position=13.6, axis_parameters=[ChannelYZMoveParameters(default_values=False, channel=, y_position=75.5, z_position=29.95)])}\n", + "DEBUG - drop_tips(tip_spots=['teaching_tip'], use_channels=None, offsets=None, allow_nonzero_volume=False)\n", + "DEBUG - PrepDropTips parameters: {'tip_positions': [TipDropParameters(default_values=False, channel=, x_position=287.76, y_position=217.29, z_position=75.75, z_seek=93.75, drop_type=)], 'final_z': 167.5, 'seek_speed': 30.0, 'tip_roll_off_distance': 0.0}\n" + ] + } + ], + "source": [ + "# Set Logger to debug so we can see params as sent to the backend.\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "teaching_tip = deck.get_resource(\"teaching_tip\")\n", + "await lh.pick_up_tips([teaching_tip])\n", + "\n", + "a1 = plate.get_item(\"A1\")\n", + "safe_pos = a1.get_absolute_location(\"c\", \"c\", \"b\") + Coordinate(0, 0, SAFE_HEIGHT_MM_ABOVE_WELL)\n", + "await lh.backend.move_to_position(safe_pos.x, safe_pos.y, safe_pos.z)\n", + "await sleep(3)\n", + "\n", + "await lh.drop_tips([teaching_tip])" + ] + }, + { + "cell_type": "markdown", + "id": "4680bbb0", + "metadata": {}, + "source": [ + "## 5. Tip pickup, aspirate, dispense (dual channel)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "5b386354", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_tips(tip_spots=['ntr_50_tipspot_A1', 'ntr_50_tipspot_B1'], use_channels=None, offsets=None)\n", + "DEBUG - PrepPickUpTips parameters: {'tip_positions': [TipPositionParameters(default_values=False, channel=, x_position=13.525, y_position=361.5, z_position=59.650000000000006, z_seek=72.65), TipPositionParameters(default_values=False, channel=, x_position=13.525, y_position=352.5, z_position=59.650000000000006, z_seek=72.65)], 'final_z': 167.5, 'seek_speed': 15.0, 'tip_definition': TipPickupParameters(default_values=False, volume=65, length=42.4, tip_type=, has_filter=False, is_needle=False, is_tool=False), 'enable_tadm': False, 'dispenser_volume': 0.0, 'dispenser_speed': 250.0}\n", + "DEBUG - aspirate(resources=['plate_well_A1', 'plate_well_B1'], vols=[35, 25], use_channels=None, flow_rates=None, offsets=None, liquid_height=[3, 3], blow_out_air_volume=None)\n", + "DEBUG - PrepAspirateNoLldMonitoringV2 parameters: {'aspirate_parameters': [AspirateParametersNoLldAndMonitoring2(default_values=False, channel=, aspirate=AspirateParameters(default_values=False, x_position=13.6, y_position=75.5, prewet_volume=2.0, blowout_volume=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=38.45, liquid_speed=100.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=1.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), aspirate_monitoring=AspirateMonitoringParameters(default_values=True, c_lld_enable=False, p_lld_enable=False, minimum_differential=30, maximum_differential=30, clot_threshold=20)), AspirateParametersNoLldAndMonitoring2(default_values=False, channel=, aspirate=AspirateParameters(default_values=False, x_position=13.6, y_position=66.5, prewet_volume=2.0, blowout_volume=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=27.75, liquid_speed=100.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=1.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), aspirate_monitoring=AspirateMonitoringParameters(default_values=True, c_lld_enable=False, p_lld_enable=False, minimum_differential=30, maximum_differential=30, clot_threshold=20))]}\n", + "DEBUG - dispense(resources=['plate_well_A7', 'plate_well_B7'], vols=[35, 25], use_channels=None, flow_rates=None, offsets=None, liquid_height=[3, 3], blow_out_air_volume=None)\n", + "DEBUG - PrepDispenseNoLldV2 parameters: {'dispense_parameters': [DispenseParametersNoLld2(default_values=False, channel=, dispense=DispenseParameters(default_values=False, x_position=67.6, y_position=75.5, stop_back_volume=0.0, cutoff_speed=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=38.45, liquid_speed=120.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=0.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), tadm=TadmParameters(default_values=True, limit_curve_index=0, recording_mode=)), DispenseParametersNoLld2(default_values=False, channel=, dispense=DispenseParameters(default_values=False, x_position=67.6, y_position=66.5, stop_back_volume=0.0, cutoff_speed=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=27.75, liquid_speed=120.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=0.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), tadm=TadmParameters(default_values=True, limit_curve_index=0, recording_mode=))]}\n", + "DEBUG - discard_tips(use_channels=None, allow_nonzero_volume=True, offsets=None)\n", + "DEBUG - drop_tips(tip_spots=['trash', 'trash'], use_channels=[0, 1], offsets=[Coordinate(x=0, y=4.5, z=0), Coordinate(x=0, y=-4.5, z=0)], allow_nonzero_volume=True)\n", + "DEBUG - PrepDropTips parameters: {'tip_positions': [TipDropParameters(default_values=False, channel=, x_position=289.8, y_position=33.0, z_position=110.80000000000001, z_seek=128.8, drop_type=), TipDropParameters(default_values=False, channel=, x_position=289.8, y_position=13.0, z_position=110.80000000000001, z_seek=128.8, drop_type=)], 'final_z': 167.5, 'seek_speed': 30.0, 'tip_roll_off_distance': 3.0}\n" + ] + } + ], + "source": [ + "tip_spots = tip_rack['A1:B1']\n", + "await lh.pick_up_tips(tip_spots)\n", + "\n", + "await lh.aspirate(\n", + " plate[\"A1:B1\"],\n", + " vols=[35, 25],\n", + " liquid_height=[3, 3],\n", + " z_liquid_exit_speed = [25, 25],\n", + ")\n", + "\n", + "await lh.dispense(\n", + " plate['A7:B7'],\n", + " vols=[35, 25],\n", + " liquid_height=[3, 3],\n", + " z_liquid_exit_speed = [25, 25],\n", + ")\n", + "\n", + "#await lh.drop_tips(tip_spots)\n", + "await lh.discard_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "42046278", + "metadata": {}, + "source": [ + "## 6. Plate movement with CORE gripper (deck[0] → deck[1])" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3c401d56", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_resource(resource=plate, offset=Coordinate(000.000, 000.000, 000.000), pickup_distance_from_top=None, direction=GripDirection.FRONT)\n", + "DEBUG - No preferred pickup location for resource plate. Using default pickup distance of 5mm.\n", + "DEBUG - PrepPickUpTool parameters: {'tip_definition': TipPickupParameters(default_values=False, volume=1.0, length=22.9, tip_type=, has_filter=False, is_needle=False, is_tool=True), 'tool_position_x': 290.0, 'tool_position_z': 62.5, 'front_channel_position_y': 257.5, 'rear_channel_position_y': 275.5, 'tool_seek': 72.5, 'tool_x_radius': 2.0, 'tool_y_radius': 2.0}\n", + "DEBUG - PrepMoveZUpToSafe parameters: {'channels': [, ]}\n", + "DEBUG - PrepPickUpPlate parameters: {'plate_top_center': XYZCoord(default_values=False, x_position=63.5, y_position=44.255, z_position=18.57), 'plate': PlateDimensions(default_values=False, length=127.0, width=85.51, height=14.82), 'clearance_y': 2.5, 'grip_speed_y': 5.0, 'grip_distance': 4.5, 'grip_height': 13.57}\n", + "DEBUG - drop_resource(destination=spot_0_1, offset=Coordinate(000.000, 000.000, 000.000), direction=GripDirection.FRONT)\n", + "DEBUG - PrepDropPlate parameters: {'plate_top_center': XYZCoord(default_values=False, x_position=63.5, y_position=139.38, z_position=13.57), 'clearance_y': 3.0, 'acceleration_scale_x': 1}\n", + "DEBUG - PrepMoveZUpToSafe parameters: {'channels': [, ]}\n", + "DEBUG - PrepDropTool parameters: {}\n" + ] + } + ], + "source": [ + "await lh.pick_up_resource(plate)\n", + "await lh.drop_resource(destination=deck[1], return_gripper=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Closing connection to socket 192.168.100.102:2000\n", + "INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n", + "\n", + "await lh.backend.park()\n", + "await lh.backend.disco_mode()\n", + "await lh.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f53fe787", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 1e229fef806..bb236be9125 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1,6 +1,8 @@ """Hamilton Nimbus backend implementation. NimbusBackend composes HamiltonTCPClient as self.client for TCP and introspection. +Callers may pass host and optionally port for default TCP settings, or inject a +pre-configured client (dependency injection). Interfaces: self.client.interfaces..address for routing. Optional presence via .is_available or firmware probe (DoorLock uses .is_available). """ @@ -10,7 +12,7 @@ import enum import logging from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union +from typing import Dict, List, Optional, overload, Sequence, Tuple, TypeVar, Union from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand @@ -441,6 +443,8 @@ class NimbusBackend(LiquidHandlerBackend): Uses HamiltonTCPClient (self.client) for TCP communication and introspection; implements LiquidHandlerBackend for liquid handling. Interfaces resolved lazily via _require() on first use. + Construction accepts either host (and optionally port) or an injected client + (dependency injection), same pattern as PrepBackend. On-demand introspection: ``await self.client.introspect(path)``. """ @@ -452,24 +456,36 @@ class NimbusBackend(LiquidHandlerBackend): "door_lock": InterfaceSpec("NimbusCORE.DoorLock", False, True), } + @overload + def __init__(self, *, host: str, port: int = 2000) -> None: ... + + @overload + def __init__(self, *, client: HamiltonTCPClient) -> None: ... + def __init__( self, - host: str, + *, + host: Optional[str] = None, port: int = 2000, - read_timeout: float = 30.0, - write_timeout: float = 30.0, - auto_reconnect: bool = True, - max_reconnect_attempts: int = 3, - ): + client: Optional[HamiltonTCPClient] = None, + ) -> None: + """Initialize Nimbus backend. + + Args: + host: Instrument hostname or IP; used when client is not provided. + port: TCP port (default 2000). + client: Optional pre-configured HamiltonTCPClient (mutually exclusive + with host). + """ super().__init__() - self.client = HamiltonTCPClient( - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=write_timeout, - auto_reconnect=auto_reconnect, - max_reconnect_attempts=max_reconnect_attempts, - ) + if client is not None: + if host is not None: + raise TypeError("Provide either host or client, not both") + self.client = client + elif host is not None: + self.client = HamiltonTCPClient(host=host, port=port) + else: + raise TypeError("Provide either host or client") self._num_channels: Optional[int] = None self._is_initialized: Optional[bool] = None diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 4f6fc7f913c..28003e3c72c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -30,6 +30,7 @@ UnlockDoor, _get_tip_type_from_tip, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, HoiParams, @@ -565,6 +566,13 @@ async def test_backend_init_default_port(self): backend = NimbusBackend(host="192.168.1.100") self.assertEqual(backend.client.io._port, 2000) + async def test_backend_init_with_client(self): + """Injected client is stored and used by the backend.""" + client = HamiltonTCPClient(host="192.168.1.1", port=2000) + backend = NimbusBackend(client=client) + self.assertIs(backend.client, client) + self.assertEqual(backend.client.io._host, "192.168.1.1") + async def test_num_channels_before_setup_raises(self): backend = NimbusBackend(host="192.168.1.100") with self.assertRaises(RuntimeError) as ctx: diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 229d1c062c6..bc36b98c008 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -5,6 +5,9 @@ - **HamiltonTCPClient** (``self.client``): Transport and introspection. All device communication goes through ``self.client.send_command()``. Address resolution: ``self.client.interfaces..address``. + The backend composes the client via dependency injection: callers pass host + (and optionally port) for default TCP settings, or pass a pre-configured + HamiltonTCPClient for full control. - **Command dataclasses** (e.g. ``PrepDropTips``, ``MphPickupTips``): Pure wire shapes. Defined in ``prep_commands.py``; ``@dataclass`` with ``dest: Address`` + @@ -23,7 +26,7 @@ import logging import math import random -from typing import List, Optional, Tuple, Union +from typing import List, Optional, overload, Tuple, Union from pylabrobot.liquid_handling.backends.hamilton.prep_commands import * # noqa: F401,F403 @@ -55,7 +58,8 @@ get_star_liquid_class, ) from pylabrobot.resources import Coordinate, Tip -from pylabrobot.resources.hamilton import HamiltonCoreGrippers, HamiltonTip, TipSize +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers from pylabrobot.resources.liquid import Liquid from pylabrobot.resources.tip_rack import TipSpot from pylabrobot.resources.trash import Trash @@ -153,6 +157,13 @@ def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): 1: ChannelIndex.FrontChannel, } +# Channel index -> deck waste resource name (PrepDeck: waste_rear, waste_front, waste_mph) +_CHANNEL_TO_WASTE_NAME = { + 0: "waste_rear", + 1: "waste_front", + 2: "waste_mph", +} + # Expected root name from discovery; validated at setup(). _EXPECTED_ROOT = "MLPrepRoot" @@ -163,6 +174,8 @@ class PrepBackend(LiquidHandlerBackend): Uses HamiltonTCPClient (self.client) for communication and introspection; implements LiquidHandlerBackend for liquid handling. Interfaces resolved lazily via _require() on first use. + Construction accepts either host (and optionally port) to create the client + with defaults, or client to inject a pre-configured HamiltonTCPClient. On-demand introspection: ``await self.client.introspect(path)``. """ @@ -177,25 +190,49 @@ class PrepBackend(LiquidHandlerBackend): "mlprep_service": InterfaceSpec("MLPrepRoot.MLPrepService", False, True), } + @overload def __init__( self, + *, host: str, port: int = 2000, - read_timeout: float = 30.0, - write_timeout: float = 30.0, - auto_reconnect: bool = True, - max_reconnect_attempts: int = 3, default_traverse_height: Optional[float] = None, - ): + ) -> None: ... + + @overload + def __init__( + self, + *, + client: HamiltonTCPClient, + default_traverse_height: Optional[float] = None, + ) -> None: ... + + def __init__( + self, + *, + host: Optional[str] = None, + port: int = 2000, + client: Optional[HamiltonTCPClient] = None, + default_traverse_height: Optional[float] = None, + ) -> None: + """Initialize Prep backend. + + Args: + host: Instrument hostname or IP; used when client is not provided. + port: TCP port (default 2000). + client: Optional pre-configured HamiltonTCPClient (mutually exclusive + with host). + default_traverse_height: Optional default traverse height in mm. + """ super().__init__() - self.client = HamiltonTCPClient( - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=write_timeout, - auto_reconnect=auto_reconnect, - max_reconnect_attempts=max_reconnect_attempts, - ) + if client is not None: + if host is not None: + raise TypeError("Provide either host or client, not both") + self.client = client + elif host is not None: + self.client = HamiltonTCPClient(host=host, port=port) + else: + raise TypeError("Provide either host or client") self._config: Optional[InstrumentConfig] = None self._user_traverse_height: Optional[float] = default_traverse_height self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) @@ -366,7 +403,7 @@ async def _get_hardware_config(self) -> InstrumentConfig: ) for s in sites_resp.sites ) - logger.info("Discovered %d deck sites", len(deck_sites)) + logger.debug("Discovered %d deck sites", len(deck_sites)) waste_resp = await self.client.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) if waste_resp and waste_resp.sites: @@ -380,7 +417,7 @@ async def _get_hardware_config(self) -> InstrumentConfig: ) for s in waste_resp.sites ) - logger.info("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + logger.debug("Discovered %d waste sites: %s", len(waste_sites), waste_sites) # Channel configuration (1 vs 2 dual-channel pipettor, 8MPH) from MLPrepService present = await self.get_present_channels() @@ -422,7 +459,7 @@ def has_mph(self) -> bool: @property def num_arms(self) -> int: """Number of resource-handling arms. 1 when deck has core_grippers and 2 channels, else 0.""" - if self.deck is None or self._num_channels is None or self._num_channels != 2: + if self._deck is None or self._num_channels is None or self._num_channels != 2: return 0 try: mount = self.deck.get_resource("core_grippers") @@ -591,7 +628,17 @@ async def drop_tips( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) + all_trash = all(isinstance(op.resource, Trash) for op in ops) + all_tip_spots = all(isinstance(op.resource, TipSpot) for op in ops) + if not (all_trash or all_tip_spots): + raise ValueError( + "Cannot mix waste (Trash) and tip spots in a single drop_tips call." + ) + resolved_final_z = self._resolve_traverse_height(final_z) + roll_off = 3.0 if (all_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + # Use Stall when dropping to waste so the pipette detects contact before release. + resolved_drop_type = TipDropType.Stall if all_trash else drop_type indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} tip_positions: List[TipDropParameters] = [] @@ -599,32 +646,22 @@ async def drop_tips( if ch not in indexed_ops: continue op = indexed_ops[ch] - loc = op.resource.get_absolute_location("c", "c", "t") tip = op.tip - # Waste positions (Trash with category waste_position): use drop height directly - # (z_position = loc.z) instead of loc.z + tip_length, and z_seek just above. - is_waste = ( - isinstance(op.resource, Trash) - and getattr(op.resource, "category", "") == "waste_position" - ) - if is_waste: - z_position = loc.z - z_seek = loc.z + 10.0 + (z_seek_offset or 0.0) - params = TipDropParameters( - default_values=False, - channel=_CHANNEL_INDEX[ch], - x_position=loc.x, - y_position=loc.y, - z_position=z_position, - z_seek=z_seek, - drop_type=drop_type, - ) + if all_trash: + waste_name = _CHANNEL_TO_WASTE_NAME.get(ch, "waste_mph") + if not self.deck.has_resource(waste_name): + raise ValueError( + f"Cannot drop tips to waste: deck has no waste position '{waste_name}'. " + "Use a deck with waste_rear, waste_front (and waste_mph if using MPH)." + ) + loc = self.deck.get_resource(waste_name).get_absolute_location("c", "c", "t") else: - params = TipDropParameters.for_op( - _CHANNEL_INDEX[ch], loc, tip, - z_seek_offset=z_seek_offset, - drop_type=drop_type, - ) + loc = op.resource.get_absolute_location("c", "c", "t") + op.offset + params = TipDropParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, + z_seek_offset=z_seek_offset, + drop_type=resolved_drop_type, + ) tip_positions.append(params) await self.client.send_command( @@ -633,7 +670,7 @@ async def drop_tips( tip_positions=tip_positions, final_z=resolved_final_z, seek_speed=seek_speed, - tip_roll_off_distance=tip_roll_off_distance, + tip_roll_off_distance=roll_off, ) ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py index b7a37c7861f..86faccfa5ad 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -803,7 +803,7 @@ def for_op( (None = 0). """ z = loc.z + (tip.total_tip_length - tip.fitting_depth) - z_seek = loc.z + tip.total_tip_length + 5.0 + (z_seek_offset or 0.0) + z_seek = loc.z + tip.total_tip_length + 10.0 + (z_seek_offset or 0.0) return cls( default_values=False, channel=channel, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 339847baedb..9f423a2fe41 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -20,6 +20,10 @@ await self.client.setup() await self.client.send_command(SomeCommand(...)) +Backends may construct the client with host/port (using this module's defaults) +or accept a pre-built client from the caller (dependency injection) so TCP +options stay in one place. + Error handling: By default (detailed_errors=True), command failures include Layer A (HC_RESULT enum description) and Layer B (async method signature / parameter diagnosis). Set detailed_errors=False to skip Layer B. @@ -324,13 +328,21 @@ async def run_setup_loop(self) -> None: for name, spec in self.interfaces.items(): if spec.required: addr = await self.require(name) - logger.info("Found interface '%s' (%s) at %s", name, spec.path, addr) + logger.debug("Found interface '%s' (%s) at %s", name, spec.path, addr) else: optional_addr = await self.get(name) if optional_addr is not None: - logger.info("Found interface '%s' (%s) at %s", name, spec.path, optional_addr) + logger.debug("Found interface '%s' (%s) at %s", name, spec.path, optional_addr) else: - logger.info("Could not find interface '%s' (%s) on instrument.", name, spec.path) + logger.debug("Could not find interface '%s' (%s) on instrument.", name, spec.path) + + found = sorted(name for name in self.interfaces if self.has_interface(name)) + optional_missing = sorted( + name for name, spec in self.interfaces.items() if not spec.required and not self.has_interface(name) + ) + logger.info("Interfaces: %s", ", ".join(found)) + if optional_missing: + logger.info("Optional not present: %s", ", ".join(optional_missing)) @dataclass @@ -372,6 +384,11 @@ class HamiltonTCPClient: interfaces (RegistryProxy): .address for required paths; .is_available or firmware probe for optional; await .resolve() for depth-2+ paths. detailed_errors=True (default) enables full diagnosis on command failure. + Connection timeout is configurable; when the connection drops, the next + send_command (with ensure_connection=True) reconnects and retries once. + Backends use composition and optional dependency injection: they may build + the client with host and port (using the defaults below) or accept an + injected instance for full control. """ def __init__( @@ -383,7 +400,22 @@ def __init__( auto_reconnect: bool = True, max_reconnect_attempts: int = 3, detailed_errors: bool = True, + connection_timeout: int = 300, ): + """Initialize the Hamilton TCP client. + + These arguments are the defaults when backends construct the client with + only host and port. + + Args: + host: Instrument hostname or IP address. + port: TCP port (default 2000). + connection_timeout: Idle timeout in seconds sent to the instrument at + connection init; if no commands are sent for this long the instrument + may close the connection. Default 300 (5 min). If the connection drops, + the next send_command (with ensure_connection=True) reconnects and + retries that command once. + """ self.io = Socket( host=host, port=port, @@ -395,6 +427,7 @@ def __init__( self.auto_reconnect = auto_reconnect self.max_reconnect_attempts = max_reconnect_attempts self.detailed_errors = detailed_errors + self._connection_timeout = connection_timeout self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} @@ -620,7 +653,13 @@ async def setup(self): root_info.children = {} self._registry.register(root_info.name, root_info) - logger.info(f"Hamilton backend setup complete. Client ID: {self._client_id}") + root_name = self.discovered_root_name() if self._registry._objects else "—" + logger.info( + "Setup complete. Registered as Client ID %s (%s), Root: %s", + self._client_id, + self.client_address, + root_name, + ) async def _initialize_connection(self): """Initialize connection using Protocol 7 (ConnectionPacket). @@ -629,14 +668,14 @@ async def _initialize_connection(self): and read the response directly (blocking) rather than using the normal routing mechanism. """ - logger.info("Initializing Hamilton connection...") + logger.debug("Initializing Hamilton connection...") # Build Protocol 7 ConnectionPacket using new InitMessage - packet = InitMessage(timeout=30).build() + packet = InitMessage(timeout=self._connection_timeout).build() - logger.info("[INIT] Sending Protocol 7 initialization packet:") - logger.info(f"[INIT] Length: {len(packet)} bytes") - logger.info(f"[INIT] Hex: {packet.hex(' ')}") + logger.debug("[INIT] Sending Protocol 7 initialization packet:") + logger.debug("[INIT] Length: %s bytes", len(packet)) + logger.debug("[INIT] Hex: %s", packet.hex(" ")) # Send packet await self.write(packet) @@ -650,9 +689,9 @@ async def _initialize_connection(self): payload_data = await self.read_exact(packet_size) response_bytes = size_data + payload_data - logger.info("[INIT] Received response:") - logger.info(f"[INIT] Length: {len(response_bytes)} bytes") - logger.info(f"[INIT] Hex: {response_bytes.hex(' ')}") + logger.debug("[INIT] Received response:") + logger.debug("[INIT] Length: %s bytes", len(response_bytes)) + logger.debug("[INIT] Hex: %s", response_bytes.hex(" ")) # Parse response using InitResponse response = InitResponse.from_bytes(response_bytes) @@ -661,11 +700,11 @@ async def _initialize_connection(self): # Controller module is 2, node is client_id, object 65535 for general addressing self.client_address = Address(2, response.client_id, 65535) - logger.info(f"[INIT] ✓ Client ID: {self._client_id}, Address: {self.client_address}") + logger.info("Connection initialized (Client ID: %s, Address: %s)", self._client_id, self.client_address) async def _register_client(self): """Register client using Protocol 3.""" - logger.info("Registering Hamilton client...") + logger.debug("Registering Hamilton client...") # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) registration_service = Address(0, 0, 65534) @@ -690,10 +729,10 @@ async def _register_client(self): harp_response_required=False, # DLL uses 0x03 (no response flag) ) - logger.info("[REGISTER] Sending registration packet:") - logger.info(f"[REGISTER] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[REGISTER] Hex: {packet.hex(' ')}") - logger.info(f"[REGISTER] Src: {self.client_address}, Dst: {registration_service}") + logger.debug("[REGISTER] Sending registration packet:") + logger.debug("[REGISTER] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[REGISTER] Hex: %s", packet.hex(" ")) + logger.debug("[REGISTER] Src: %s, Dst: %s", self.client_address, registration_service) # Send registration packet await self.write(packet) @@ -701,15 +740,15 @@ async def _register_client(self): # Read response response = await self._read_one_message() - logger.info("[REGISTER] Received response:") - logger.info(f"[REGISTER] Length: {len(response.raw_bytes)} bytes") - logger.debug(f"[REGISTER] Hex: {response.raw_bytes.hex(' ')}") + logger.debug("[REGISTER] Received response:") + logger.debug("[REGISTER] Length: %s bytes", len(response.raw_bytes)) + logger.debug("[REGISTER] Hex: %s", response.raw_bytes.hex(" ")) - logger.info("[REGISTER] ✓ Registration complete") + logger.info("Client registered.") async def _discover_root(self): """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" - logger.info("Discovering Hamilton root objects...") + logger.debug("Discovering Hamilton root objects...") registration_service = Address(0, 0, 65534) @@ -737,9 +776,9 @@ async def _discover_root(self): harp_response_required=True, # Request with response ) - logger.info("[DISCOVER_ROOT] Sending root object discovery:") - logger.info(f"[DISCOVER_ROOT] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[DISCOVER_ROOT] Hex: {packet.hex(' ')}") + logger.debug("[DISCOVER_ROOT] Sending root object discovery:") + logger.debug("[DISCOVER_ROOT] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[DISCOVER_ROOT] Hex: %s", packet.hex(" ")) # Send request await self.write(packet) @@ -748,15 +787,15 @@ async def _discover_root(self): response = await self._read_one_message() assert isinstance(response, RegistrationResponse) - logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} bytes") + logger.debug("[DISCOVER_ROOT] Received response: %s bytes", len(response.raw_bytes)) # Parse registration response to extract root object IDs root_objects = self._parse_registration_response(response) - logger.info(f"[DISCOVER_ROOT] ✓ Found {len(root_objects)} root objects") + logger.debug("[DISCOVER_ROOT] Found %s root objects", len(root_objects)) self._registry.set_root_addresses(root_objects) - logger.info(f"✓ Discovery complete: {len(root_objects)} root objects") + logger.debug("Discovery complete: %s root objects", len(root_objects)) async def _discover_globals(self): """Discover global objects via Protocol 3 HARP_PROTOCOL_REQUEST. @@ -765,7 +804,7 @@ async def _discover_globals(self): source_id=1 in method parameter triples. Piglet calls these "globals" and uses request_id=2 (GLOBAL_OBJECT_ADDRESS) to discover them. """ - logger.info("Discovering Hamilton global objects...") + logger.debug("Discovering Hamilton global objects...") registration_service = Address(0, 0, 65534) @@ -791,9 +830,9 @@ async def _discover_globals(self): harp_response_required=True, ) - logger.info("[DISCOVER_GLOBALS] Sending global object discovery:") - logger.info(f"[DISCOVER_GLOBALS] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[DISCOVER_GLOBALS] Hex: {packet.hex(' ')}") + logger.debug("[DISCOVER_GLOBALS] Sending global object discovery:") + logger.debug("[DISCOVER_GLOBALS] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[DISCOVER_GLOBALS] Hex: %s", packet.hex(" ")) await self.write(packet) @@ -802,7 +841,7 @@ async def _discover_globals(self): global_objects = self._parse_registration_response(response) self._global_object_addresses = global_objects - logger.info(f"[DISCOVER_GLOBALS] ✓ Found {len(global_objects)} global objects") + logger.debug("[DISCOVER_GLOBALS] Found %s global objects", len(global_objects)) def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 99d31a55c9c..24f2dc61f3d 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -650,7 +650,7 @@ def __init__( self, name="deck", size_x=300.0, - size_y=320.0, + size_y=394.0, size_z=0, origin=Coordinate.zero(), category="deck", @@ -667,21 +667,21 @@ def __init__( for column in range(2): for row in range(4): x = column * 140 - y = (row * 95) + 2 # TODO: Check if this offset is valid on other systems (plastic corner stay?) + y = row * 95.125 spot = ResourceHolder( name=f"spot_{column}_{row}", size_x=127.76, size_y=92, size_z=12.5, - child_location=Coordinate(0, 0, 3.75), + child_location=Coordinate(0, 1.5, 3.75), # Adjusted for plastic corner mounts TODO: Validate on other systems ) self.assign_child_resource(spot, location=Coordinate(x, y, 0)) spots_list.append(spot) self.spots: List[ResourceHolder] = spots_list - trash = Trash(name="trash", size_x=0, size_y=0, size_z=0) + trash = Trash(name="trash", size_x=13, size_y=132.7, size_z=73) # TODO: y coordinate - self.assign_child_resource(trash, location=Coordinate(287.0, 0, 0)) + self.assign_child_resource(trash, location=Coordinate(280.3, -3, 0)) # Same tip definition as STAR teaching rack: 300uL tip. Backend sends z_position = # Slot height=83 mm (measured from tip top); 300uL filter: total_tip_length=59.9 mm + fine adjustment @@ -705,14 +705,14 @@ def __init__( for waste_name, y_pos in [("waste_rear", 30.0), ("waste_front", 10.0), ("waste_mph", 112.0)]: waste = Trash( name=waste_name, - size_x=0.0, - size_y=0.0, - size_z=68.4, + size_x=6.0, + size_y=6.0, + size_z=0.0, category="waste_position", ) self.assign_child_resource( waste, - location=Coordinate(x=286.8, y=y_pos, z=0.0), + location=Coordinate(x=286.8, y=y_pos, z=68.4), ) def __getitem__(self, key: int) -> ResourceHolder: From 8254c98af8bf66aaf981e0fa660fb76b040ebea8 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:46:02 -0700 Subject: [PATCH 24/42] 50ul tip volume mapping for star. (Need for testing) --- .../liquid_handling/liquid_classes/hamilton/star.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py index 81fd3dee5fe..11f6f5e9e2b 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py +++ b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py @@ -41,6 +41,8 @@ def get_star_liquid_class( tip_volume = int( { 360.0: 300.0, + 60.0: 50.0, + 65.0: 50.0, 1065.0: 1000.0, 1250.0: 1000.0, 4367.0: 4000.0, @@ -1050,7 +1052,6 @@ def get_star_liquid_class( dispense_stop_back_volume=0.0, ) - star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( _250ul_Piercing_Tip_Water_DispenseSurface_Empty ) = HamiltonLiquidClass( @@ -15003,3 +15004,12 @@ def get_star_liquid_class( dispense_stop_flow_rate=1.0, dispense_stop_back_volume=0.0, ) + +# Default (no jet, no blow_out) for 50 µL water: alias to Surface_Empty (closest existing class). +star_mapping[(50, False, True, False, Liquid.WATER, False, False)] = ( + Tip_50ul_Water_DispenseSurface +) = Tip_50ul_Water_DispenseSurface_Empty + +star_mapping[(50, False, True, True, Liquid.WATER, False, False)] = ( + Tip_50ulFilter_Water_DispenseSurface +) = Tip_50ulFilter_Water_DispenseSurface_Empty From 152f76b492d70aa9b42b3d6c4064934d4f1b51d5 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:46:16 -0700 Subject: [PATCH 25/42] Add Tests --- .../backends/hamilton/prep_backend_tests.py | 1122 +++++++++++++++++ 1 file changed, 1122 insertions(+) create mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py new file mode 100644 index 00000000000..9bcba2a0b7a --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py @@ -0,0 +1,1122 @@ +"""Tests for Hamilton Prep backend logic and command generation. + +Verifies PrepBackend method behavior: how operations are transformed into +commands, geometry computed, command variants dispatched, and state managed. +All tests mock client.send_command — no real TCP connection required. +""" + +import math +import unittest +import unittest.mock + +from pylabrobot.liquid_handling.backends.hamilton.prep_backend import ( + PrepBackend, + _absolute_z_from_well, + _build_container_segments, + _effective_radius, +) +from pylabrobot.liquid_handling.backends.hamilton.prep_commands import ( + ChannelIndex, + DeckBounds, + InstrumentConfig, + LldParameters, + MphDropTips, + MphPickupTips, + MonitoringMode, + PrepAspirateNoLldMonitoringV2, + PrepAspirateWithLldTadmV2, + PrepAspirateWithLldV2, + PrepAspirateTadmV2, + PrepDispenseNoLldV2, + PrepDispenseWithLldV2, + PrepDropPlate, + PrepDropTips, + PrepDropTool, + PrepMethodBegin, + PrepMethodEnd, + PrepMethodAbort, + PrepMovePlate, + PrepMoveToPosition, + PrepMoveToPositionViaLane, + PrepMoveZUpToSafe, + PrepPark, + PrepPickUpPlate, + PrepPickUpTips, + PrepPickUpTool, + PrepSetDeckLight, + PrepSpread, + SegmentDescriptor, + TipDropType, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.liquid_classes.hamilton import get_star_liquid_class +from pylabrobot.liquid_handling.standard import ( + Drop, + GripDirection, + Pickup, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.hamilton.hamilton_decks import PrepDeck +from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL_filter +from pylabrobot.resources.liquid import Liquid +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well + + +# ============================================================================= +# Setup helpers +# ============================================================================= + +_MLPREP_ADDR = Address(1, 1, 0x0015) +_PIPETTOR_ADDR = Address(1, 1, 0x00E0) +_COORD_ADDR = Address(1, 1, 0x00C0) +_DECK_CONFIG_ADDR = Address(1, 1, 0x00D0) +_MPH_ADDR = Address(1, 1, 0x00F0) +_SERVICE_ADDR = Address(1, 1, 0x0017) + +_TRAVERSE_HEIGHT = 96.97 + + +def _setup_backend(num_channels: int = 2, has_mph: bool = False) -> PrepBackend: + """PrepBackend with pre-resolved interfaces, bypassing TCP.""" + backend = PrepBackend(host="192.168.100.102", port=2000) + backend._num_channels = num_channels + backend._has_mph = has_mph + backend._user_traverse_height = _TRAVERSE_HEIGHT + backend._config = InstrumentConfig( + deck_bounds=DeckBounds(0.0, 300.0, 0.0, 320.0, 0.0, 100.0), + has_enclosure=False, + safe_speeds_enabled=False, + deck_sites=(), + waste_sites=(), + default_traverse_height=_TRAVERSE_HEIGHT, + num_channels=num_channels, + has_mph=has_mph, + ) + backend._resolver._resolved["mlprep"] = _MLPREP_ADDR + backend._resolver._resolved["pipettor"] = _PIPETTOR_ADDR + backend._resolver._resolved["coordinator"] = _COORD_ADDR + backend._resolver._resolved["deck_config"] = _DECK_CONFIG_ADDR + backend._resolver._resolved["mph"] = _MPH_ADDR if has_mph else None + backend._resolver._resolved["mlprep_service"] = _SERVICE_ADDR + backend.setup_finished = True + return backend + + +def _setup_backend_with_deck( + num_channels: int = 2, + has_mph: bool = False, + with_core_grippers: bool = False, +) -> tuple: + """Returns (backend, deck, tip_rack, plate).""" + backend = _setup_backend(num_channels=num_channels, has_mph=has_mph) + deck = PrepDeck(with_core_grippers=with_core_grippers) + backend._deck = deck + + tip_rack = hamilton_96_tiprack_300uL_filter("tip_rack") + deck[0] = tip_rack + + plate = Cor_96_wellplate_360ul_Fb("plate") + deck[1] = plate + + return backend, deck, tip_rack, plate + + +def _get_commands(mock_send, cmd_type): + """Extract sent commands of a specific type from mock call list.""" + return [ + call.args[0] + for call in mock_send.call_args_list + if isinstance(call.args[0], cmd_type) + ] + + +# ============================================================================= +# 1. Helper function logic +# ============================================================================= + + +class TestPrepHelperFunctions(unittest.TestCase): + """Tests for pure geometry helper functions — no mocking.""" + + def _make_circular_well(self, diameter: float, height: float) -> Well: + return Well( + name="w", + size_x=diameter, + size_y=diameter, + size_z=height, + cross_section_type=CrossSectionType.CIRCLE, + ) + + def _make_rect_well(self, x: float, y: float, height: float) -> Well: + return Well( + name="w", + size_x=x, + size_y=y, + size_z=height, + cross_section_type=CrossSectionType.RECTANGLE, + ) + + # --- _effective_radius --- + + def test_effective_radius_circular(self): + well = self._make_circular_well(diameter=8.0, height=10.0) + self.assertAlmostEqual(_effective_radius(well), 4.0) + + def test_effective_radius_rectangular(self): + well = self._make_rect_well(x=6.0, y=4.0, height=10.0) + expected = math.sqrt(6.0 * 4.0 / math.pi) + self.assertAlmostEqual(_effective_radius(well), expected) + + def test_effective_radius_non_well_uses_size_x(self): + # For non-Well objects the function falls back to size_x / 2 + from pylabrobot.resources import Resource + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) + self.assertAlmostEqual(_effective_radius(resource), 5.0) + + # --- _build_container_segments --- + + def test_build_container_segments_non_well(self): + from pylabrobot.resources import Resource + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) + segs = _build_container_segments(resource) + self.assertEqual(segs, []) + + def test_build_container_segments_simple_circular(self): + well = self._make_circular_well(diameter=6.0, height=10.0) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 1) + expected_area = math.pi * (3.0 ** 2) + self.assertIsInstance(segs[0], SegmentDescriptor) + self.assertAlmostEqual(segs[0].area_top, expected_area, places=4) + self.assertAlmostEqual(segs[0].area_bottom, expected_area, places=4) + self.assertAlmostEqual(segs[0].height, 10.0, places=4) + + def test_build_container_segments_simple_rect(self): + well = self._make_rect_well(x=6.0, y=4.0, height=10.0) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 1) + expected_area = 6.0 * 4.0 + self.assertAlmostEqual(segs[0].area_top, expected_area, places=4) + self.assertAlmostEqual(segs[0].height, 10.0, places=4) + + def test_build_container_segments_heights_sum_to_size_z(self): + """Wells with compute_height_volume should produce 10 segments summing to size_z.""" + area = math.pi * 3.0 ** 2 + well = Well( + name="w", + size_x=6.0, + size_y=6.0, + size_z=10.0, + cross_section_type=CrossSectionType.CIRCLE, + compute_volume_from_height=lambda h: area * h, + compute_height_from_volume=lambda v: v / area, + ) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 10) + total_height = sum(s.height for s in segs) + self.assertAlmostEqual(total_height, well.get_size_z(), places=4) + + # --- _absolute_z_from_well --- + + def test_absolute_z_from_well_geometry(self): + well = self._make_circular_well(diameter=6.0, height=10.0) + deck = PrepDeck() + deck[0] = Cor_96_wellplate_360ul_Fb("p") + plate = deck[0].resource + assert plate is not None and isinstance(plate, Plate) + # Use a plate well with known absolute location + from pylabrobot.liquid_handling.standard import SingleChannelAspiration + tip = hamilton_96_tiprack_300uL_filter("tr").get_item("A1").get_tip() + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=tip, + volume=10.0, + flow_rate=None, + liquid_height=0.0, + blow_out_air_volume=None, + mix=None, + ) + well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) + size_z = op.resource.get_size_z() + # liquid_surface_z = well_bottom_z + liquid_height + self.assertAlmostEqual(liquid_surface_z - well_bottom_z, op.liquid_height or 0.0, places=4) + # top_of_well_z = well_bottom_z + size_z (cavity location is at cavity_bottom) + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + self.assertAlmostEqual(top_of_well_z, loc.z + size_z, places=4) + # z_air_z = top_of_well_z + 2mm margin + self.assertAlmostEqual(z_air_z, top_of_well_z + 2.0, places=4) + + def test_absolute_z_from_well_liquid_height_offset(self): + plate = Cor_96_wellplate_360ul_Fb("p") + deck = PrepDeck() + deck[0] = plate + tip = hamilton_96_tiprack_300uL_filter("tr").get_item("A1").get_tip() + well = plate.get_item("A1") + + def make_op(liquid_height): + return SingleChannelAspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=10.0, + flow_rate=None, + liquid_height=liquid_height, + blow_out_air_volume=None, + mix=None, + ) + + _, ls_0, _, _ = _absolute_z_from_well(make_op(0.0)) + _, ls_5, _, _ = _absolute_z_from_well(make_op(5.0)) + self.assertAlmostEqual(ls_5 - ls_0, 5.0, places=4) + + +# ============================================================================= +# 2. Backend unit tests (properties, state, validation) +# ============================================================================= + + +class TestPrepBackendUnit(unittest.TestCase): + """Backend construction, properties, and traverse height resolution.""" + + def test_num_channels_raises_before_setup(self): + backend = PrepBackend(host="localhost", port=2000) + with self.assertRaises(RuntimeError): + _ = backend.num_channels + + def test_has_mph_false_before_setup(self): + backend = PrepBackend(host="localhost", port=2000) + self.assertFalse(backend.has_mph) + + def test_num_arms_no_deck(self): + # Backend without _deck set (never assigned) + backend = PrepBackend(host="localhost", port=2000) + backend._num_channels = 2 + self.assertEqual(backend.num_arms, 0) + + def test_num_arms_with_core_grippers(self): + backend, _, _, _ = _setup_backend_with_deck(with_core_grippers=True) + self.assertEqual(backend.num_arms, 1) + + def test_num_arms_without_core_grippers(self): + backend, _, _, _ = _setup_backend_with_deck(with_core_grippers=False) + self.assertEqual(backend.num_arms, 0) + + def test_resolve_traverse_height_explicit(self): + backend = _setup_backend() + self.assertAlmostEqual(backend._resolve_traverse_height(50.0), 50.0) + + def test_resolve_traverse_height_user_set(self): + backend = PrepBackend(host="localhost", port=2000) + backend._user_traverse_height = 80.0 + self.assertAlmostEqual(backend._resolve_traverse_height(None), 80.0) + + def test_resolve_traverse_height_probed(self): + backend = PrepBackend(host="localhost", port=2000) + backend._config = InstrumentConfig( + deck_bounds=None, has_enclosure=False, safe_speeds_enabled=False, + deck_sites=(), waste_sites=(), default_traverse_height=75.0, + ) + self.assertAlmostEqual(backend._resolve_traverse_height(None), 75.0) + + def test_resolve_traverse_height_nothing_raises(self): + backend = PrepBackend(host="localhost", port=2000) + with self.assertRaises(RuntimeError): + backend._resolve_traverse_height(None) + + def test_set_default_traverse_height(self): + backend = PrepBackend(host="localhost", port=2000) + backend.set_default_traverse_height(88.0) + self.assertAlmostEqual(backend._resolve_traverse_height(None), 88.0) + + def test_resolve_traverse_height_explicit_beats_user(self): + backend = _setup_backend() + backend._user_traverse_height = 80.0 + self.assertAlmostEqual(backend._resolve_traverse_height(50.0), 50.0) + + def test_can_pick_up_tip_hamilton_tip(self): + backend = _setup_backend() + tip = HamiltonTip( + name="t", has_filter=False, total_tip_length=59.9, maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertTrue(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_non_hamilton(self): + from pylabrobot.resources import Tip + backend = _setup_backend() + tip = Tip( + name="generic_tip", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + fitting_depth=8.0, + ) + self.assertFalse(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_xl_rejected(self): + backend = _setup_backend() + tip = HamiltonTip( + name="t", has_filter=False, total_tip_length=95.0, maximal_volume=5000.0, + tip_size=TipSize.XL, pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertFalse(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_channel_out_of_range(self): + backend = _setup_backend(num_channels=2) + tip = HamiltonTip( + name="t", has_filter=False, total_tip_length=59.9, maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertFalse(backend.can_pick_up_tip(2, tip)) + + def test_not_implemented_96_head_methods(self): + import asyncio + backend = _setup_backend() + with self.assertRaises(NotImplementedError): + asyncio.run(backend.pick_up_tips96(None)) # type: ignore[arg-type] + + +# ============================================================================= +# 3. Tip pick-up and drop +# ============================================================================= + + +class TestPrepBackendTipOps(unittest.IsolatedAsyncioTestCase): + """Tip pickup/drop: channel mapping, Z geometry, waste handling.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + # --- pick_up_tips --- + + async def test_pick_up_tips_single_channel_ch0(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmds = _get_commands(self.mock_send, PrepPickUpTips) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertEqual(cmd.dest, _PIPETTOR_ADDR) + self.assertEqual(len(cmd.tip_positions), 1) + tp = cmd.tip_positions[0] + self.assertEqual(tp.channel, ChannelIndex.RearChannel) + + # Verify Z geometry + loc = tip_spot.get_absolute_location("c", "c", "t") + expected_z = loc.z + tip.total_tip_length - tip.fitting_depth + expected_z_seek = expected_z + tip.fitting_depth + 5.0 + self.assertAlmostEqual(tp.z_position, expected_z, places=3) + self.assertAlmostEqual(tp.z_seek, expected_z_seek, places=3) + self.assertAlmostEqual(tp.x_position, loc.x, places=3) + self.assertAlmostEqual(tp.y_position, loc.y, places=3) + + async def test_pick_up_tips_two_channels(self): + spot_a = self.tip_rack.get_item("A1") + spot_b = self.tip_rack.get_item("B1") + await self.backend.pick_up_tips( + [ + Pickup(resource=spot_a, offset=Coordinate.zero(), tip=spot_a.get_tip()), + Pickup(resource=spot_b, offset=Coordinate.zero(), tip=spot_b.get_tip()), + ], + use_channels=[0, 1], + ) + cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + self.assertEqual(len(cmd.tip_positions), 2) + channels = [tp.channel for tp in cmd.tip_positions] + self.assertIn(ChannelIndex.RearChannel, channels) + self.assertIn(ChannelIndex.FrontChannel, channels) + + async def test_pick_up_tips_custom_final_z(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + final_z=55.0, + ) + cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + self.assertAlmostEqual(cmd.final_z, 55.0) + + async def test_pick_up_tips_default_final_z_from_traverse(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + self.assertAlmostEqual(cmd.final_z, _TRAVERSE_HEIGHT) + + async def test_pick_up_tips_z_seek_offset(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + z_seek_offset=3.0, + ) + cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + tp = cmd.tip_positions[0] + loc = tip_spot.get_absolute_location("c", "c", "t") + base_z = loc.z + tip.total_tip_length - tip.fitting_depth + expected_z_seek = base_z + tip.fitting_depth + 5.0 + 3.0 + self.assertAlmostEqual(tp.z_seek, expected_z_seek, places=3) + + async def test_pick_up_tips_channel_out_of_range(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + with self.assertRaises(AssertionError): + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[2], # num_channels=2, valid range 0-1 + ) + + # --- drop_tips --- + + async def test_drop_tips_to_rack_uses_tip_geometry(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepDropTips)[0] + self.assertEqual(len(cmd.tip_positions), 1) + dp = cmd.tip_positions[0] + loc = tip_spot.get_absolute_location("c", "c", "t") + expected_z = loc.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek = loc.z + tip.total_tip_length + 10.0 + self.assertAlmostEqual(dp.z_position, expected_z, places=3) + self.assertAlmostEqual(dp.z_seek, expected_z_seek, places=3) + + async def test_drop_tips_to_waste_position(self): + waste = self.deck.get_resource("waste_rear") + tip = self.tip_rack.get_item("A1").get_tip() + await self.backend.drop_tips( + [Drop(resource=waste, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepDropTips)[0] + dp = cmd.tip_positions[0] + loc = waste.get_absolute_location("c", "c", "t") + # Waste: same as tip spots — z_position so tip bottom lands at surface; z_seek for approach + expected_z = loc.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek = loc.z + tip.total_tip_length + 10.0 + self.assertAlmostEqual(dp.z_position, expected_z, places=3) + self.assertAlmostEqual(dp.z_seek, expected_z_seek, places=3) + # Default roll-off when all Trash; use Stall so pipette detects contact before release + self.assertAlmostEqual(cmd.tip_roll_off_distance, 3.0) + self.assertEqual(dp.drop_type, TipDropType.Stall) + + async def test_drop_tips_stall_type(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + drop_type=TipDropType.Stall, + ) + cmd = _get_commands(self.mock_send, PrepDropTips)[0] + self.assertEqual(cmd.tip_positions[0].drop_type, TipDropType.Stall) + + async def test_drop_tips_roll_off_distance(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + tip_roll_off_distance=2.5, + ) + cmd = _get_commands(self.mock_send, PrepDropTips)[0] + self.assertAlmostEqual(cmd.tip_roll_off_distance, 2.5) + + async def test_drop_tips_all_trash_resolves_to_deck_waste_and_default_roll(self): + """When all ops are Trash (e.g. discard_tips), resolve to waste_rear/waste_front; default roll 3mm.""" + trash = self.deck.get_trash_area() + tip = self.tip_rack.get_item("A1").get_tip() + tip2 = self.tip_rack.get_item("B1").get_tip() + await self.backend.drop_tips( + [ + Drop(resource=trash, offset=Coordinate.zero(), tip=tip), + Drop(resource=trash, offset=Coordinate.zero(), tip=tip2), + ], + use_channels=[0, 1], + ) + cmd = _get_commands(self.mock_send, PrepDropTips)[0] + self.assertEqual(len(cmd.tip_positions), 2) + waste_rear = self.deck.get_resource("waste_rear") + waste_front = self.deck.get_resource("waste_front") + loc_rear = waste_rear.get_absolute_location("c", "c", "t") + loc_front = waste_front.get_absolute_location("c", "c", "t") + dp0, dp1 = cmd.tip_positions[0], cmd.tip_positions[1] + expected_z_rear = loc_rear.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek_rear = loc_rear.z + tip.total_tip_length + 10.0 + expected_z_front = loc_front.z + (tip2.total_tip_length - tip2.fitting_depth) + expected_z_seek_front = loc_front.z + tip2.total_tip_length + 10.0 + self.assertAlmostEqual(dp0.z_position, expected_z_rear, places=3) + self.assertAlmostEqual(dp0.z_seek, expected_z_seek_rear, places=3) + self.assertAlmostEqual(dp1.z_position, expected_z_front, places=3) + self.assertAlmostEqual(dp1.z_seek, expected_z_seek_front, places=3) + self.assertAlmostEqual(cmd.tip_roll_off_distance, 3.0) + + async def test_drop_tips_all_trash_deck_missing_waste_raises(self): + """When all ops are Trash but deck has no waste_rear/waste_front, raise ValueError.""" + backend = _setup_backend() + deck = Deck(size_x=300.0, size_y=320.0, size_z=0.0) + trash = Trash(name="trash", size_x=0.0, size_y=0.0, size_z=0.0) + deck.assign_child_resource(trash, location=Coordinate(287.0, 0.0, 0.0)) + backend._deck = deck + backend.client.send_command = unittest.mock.AsyncMock(return_value=None) + tip = hamilton_96_tiprack_300uL_filter("_tmp").get_item("A1").get_tip() + with self.assertRaises(ValueError) as ctx: + await backend.drop_tips( + [Drop(resource=trash, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + self.assertIn("waste_rear", str(ctx.exception)) + self.assertIn("deck has no waste position", str(ctx.exception)) + + async def test_drop_tips_mixed_trash_and_tip_spot_raises(self): + """Mixing Trash and TipSpot in one drop_tips call raises ValueError.""" + trash = self.deck.get_trash_area() + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + tip2 = self.tip_rack.get_item("B1").get_tip() + with self.assertRaises(ValueError) as ctx: + await self.backend.drop_tips( + [ + Drop(resource=trash, offset=Coordinate.zero(), tip=tip), + Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip2), + ], + use_channels=[0, 1], + ) + self.assertIn("Cannot mix waste", str(ctx.exception)) + + +# ============================================================================= +# 4. Aspirate dispatch and parameter logic +# ============================================================================= + + +class TestPrepBackendAspirate(unittest.IsolatedAsyncioTestCase): + """Aspirate: 4-way dispatch, liquid class defaults, volume correction, Z geometry.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + self.tip = self.tip_rack.get_item("A1").get_tip() + + def _make_asp(self, well_name="A1", volume=100.0, flow_rate=None, + liquid_height=5.0, blow_out_air_volume=0.0): + return SingleChannelAspiration( + resource=self.plate.get_item(well_name), + offset=Coordinate.zero(), + tip=self.tip, + volume=volume, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=None, + ) + + # --- Dispatch --- + + async def test_aspirate_default_sends_nolld_monitoring(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)), 1) + + async def test_aspirate_tadm_mode(self): + await self.backend.aspirate( + [self._make_asp()], use_channels=[0], + monitoring_mode=MonitoringMode.TADM, + ) + self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateTadmV2)), 1) + + async def test_aspirate_lld_mode(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0], use_lld=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateWithLldV2)), 1) + + async def test_aspirate_lld_tadm_mode(self): + await self.backend.aspirate( + [self._make_asp()], use_channels=[0], + use_lld=True, monitoring_mode=MonitoringMode.TADM, + ) + self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateWithLldTadmV2)), 1) + + async def test_aspirate_implicit_lld_via_lld_param(self): + """Passing lld= activates LLD path without use_lld=True.""" + custom_lld = LldParameters( + default_values=False, z_seek=90.0, z_seek_speed=5.0, z_submerge=2.0, z_out_of_liquid=1.0, + ) + await self.backend.aspirate( + [self._make_asp()], use_channels=[0], lld=custom_lld, + ) + cmds = _get_commands(self.mock_send, PrepAspirateWithLldV2) + self.assertEqual(len(cmds), 1) + # Verify the provided LLD parameters are used (not auto-derived) + lld = cmds[0].aspirate_parameters[0].lld + self.assertAlmostEqual(lld.z_seek, 90.0) + + async def test_aspirate_lld_auto_seek_z(self): + """Auto-derived LLD z_seek equals the top-of-well Z.""" + well = self.plate.get_item("A1") + op = self._make_asp() + _, _, top_of_well_z, _ = _absolute_z_from_well(op) + await self.backend.aspirate([op], use_channels=[0], use_lld=True) + cmd = _get_commands(self.mock_send, PrepAspirateWithLldV2)[0] + lld = cmd.aspirate_parameters[0].lld + self.assertAlmostEqual(lld.z_seek, top_of_well_z, places=3) + + # --- Channel mapping --- + + async def test_aspirate_channel_0_is_rear(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, ChannelIndex.RearChannel) + + async def test_aspirate_channel_1_is_front(self): + await self.backend.aspirate([self._make_asp()], use_channels=[1]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, ChannelIndex.FrontChannel) + + async def test_aspirate_two_channels(self): + ops = [self._make_asp("A1", volume=100.0, flow_rate=50.0), + self._make_asp("B1", volume=150.0, flow_rate=75.0)] + await self.backend.aspirate(ops, use_channels=[0, 1]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(len(cmd.aspirate_parameters), 2) + channels = {p.channel for p in cmd.aspirate_parameters} + self.assertIn(ChannelIndex.RearChannel, channels) + self.assertIn(ChannelIndex.FrontChannel, channels) + + # --- Volume and flow rate --- + + async def test_aspirate_volume_corrected_by_hlc(self): + """HLC-corrected volume is sent, not raw op.volume.""" + op = self._make_asp(volume=100.0) + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, + has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + ) + if hlc is not None: + expected_vol = hlc.compute_corrected_volume(100.0) + else: + expected_vol = 100.0 + await self.backend.aspirate([op], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + actual_vol = cmd.aspirate_parameters[0].common.liquid_volume + self.assertAlmostEqual(actual_vol, expected_vol, places=2) + + async def test_aspirate_disable_volume_correction(self): + """Raw volume used when disable_volume_correction=True.""" + raw_volume = 100.0 + await self.backend.aspirate( + [self._make_asp(volume=raw_volume)], use_channels=[0], + disable_volume_correction=[True], + ) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + actual_vol = cmd.aspirate_parameters[0].common.liquid_volume + self.assertAlmostEqual(actual_vol, raw_volume, places=2) + + async def test_aspirate_explicit_flow_rate(self): + await self.backend.aspirate([self._make_asp(flow_rate=60.0)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, 60.0) + + async def test_aspirate_flow_rate_from_hlc_default(self): + """flow_rate=None -> uses HLC aspiration_flow_rate.""" + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, + has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + ) + await self.backend.aspirate([self._make_asp(flow_rate=None)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + expected = hlc.aspiration_flow_rate if hlc is not None else 100.0 + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, expected, places=2) + + async def test_aspirate_explicit_settling_time_override(self): + await self.backend.aspirate( + [self._make_asp()], use_channels=[0], + settling_time=[2.0], + ) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, 2.0) + + async def test_aspirate_hlc_settling_time_default(self): + """Settling time from HLC when not explicitly passed.""" + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, + has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + ) + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + expected = hlc.aspiration_settling_time if hlc is not None else 1.0 + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, expected, places=3) + + async def test_aspirate_auto_container_geometry(self): + """auto_container_geometry=True produces non-empty container_description.""" + await self.backend.aspirate( + [self._make_asp()], use_channels=[0], + auto_container_geometry=True, + ) + cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + segs = cmd.aspirate_parameters[0].container_description + self.assertGreater(len(segs), 0) + self.assertIsInstance(segs[0], SegmentDescriptor) + + +# ============================================================================= +# 5. Dispense dispatch and parameter logic +# ============================================================================= + + +class TestPrepBackendDispense(unittest.IsolatedAsyncioTestCase): + """Dispense: 2-way dispatch, volume correction, HLC defaults.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + self.tip = self.tip_rack.get_item("A1").get_tip() + + def _make_disp(self, well_name="A1", volume=100.0, flow_rate=None, + liquid_height=5.0, blow_out_air_volume=0.0): + return SingleChannelDispense( + resource=self.plate.get_item(well_name), + offset=Coordinate.zero(), + tip=self.tip, + volume=volume, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=None, + ) + + async def test_dispense_default_sends_nolld(self): + await self.backend.dispense([self._make_disp()], use_channels=[0]) + self.assertEqual(len(_get_commands(self.mock_send, PrepDispenseNoLldV2)), 1) + + async def test_dispense_lld_mode(self): + await self.backend.dispense([self._make_disp()], use_channels=[0], use_lld=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepDispenseWithLldV2)), 1) + + async def test_dispense_volume_corrected(self): + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, + has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + ) + raw = 100.0 + expected = hlc.compute_corrected_volume(raw) if hlc else raw + await self.backend.dispense([self._make_disp(volume=raw)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].common.liquid_volume, expected, places=2) + + async def test_dispense_explicit_stop_back_volume(self): + await self.backend.dispense( + [self._make_disp()], use_channels=[0], + stop_back_volume=[3.0], + ) + cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.stop_back_volume, 3.0) + + async def test_dispense_explicit_cutoff_speed(self): + await self.backend.dispense( + [self._make_disp()], use_channels=[0], + cutoff_speed=[75.0], + ) + cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.cutoff_speed, 75.0) + + async def test_dispense_two_channels(self): + ops = [self._make_disp("A1", volume=100.0), self._make_disp("B1", volume=200.0)] + await self.backend.dispense(ops, use_channels=[0, 1]) + cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + self.assertEqual(len(cmd.dispense_parameters), 2) + + async def test_dispense_z_minimum_from_well_bottom(self): + op = self._make_disp() + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + await self.backend.dispense([op], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].common.z_minimum, loc.z, places=3) + + +# ============================================================================= +# 6. MPH head +# ============================================================================= + + +class TestPrepBackendMPH(unittest.IsolatedAsyncioTestCase): + """MPH head: single Struct (not StructArray), tip_mask, guard checks.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck(has_mph=True) + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + async def test_mph_pickup_sends_mph_command(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot) + self.assertEqual(len(_get_commands(self.mock_send, MphPickupTips)), 1) + # Must not send single-channel PrepPickUpTips + self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpTips)), 0) + + async def test_mph_pickup_default_tip_mask(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot) + cmd = _get_commands(self.mock_send, MphPickupTips)[0] + self.assertEqual(cmd.tip_mask, 0xFF) + + async def test_mph_pickup_custom_tip_mask(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot, tip_mask=0x0F) + cmd = _get_commands(self.mock_send, MphPickupTips)[0] + self.assertEqual(cmd.tip_mask, 0x0F) + + async def test_mph_drop_sends_mph_command(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.drop_tips_mph(tip_spot) + self.assertEqual(len(_get_commands(self.mock_send, MphDropTips)), 1) + + async def test_mph_pickup_raises_when_no_mph(self): + backend = _setup_backend(has_mph=False) + with self.assertRaises(RuntimeError): + await backend.pick_up_tips_mph(self.tip_rack.get_item("A1")) + + async def test_mph_pickup_raises_empty_list(self): + with self.assertRaises(ValueError): + await self.backend.pick_up_tips_mph([]) + + +# ============================================================================= +# 7. CORE gripper +# ============================================================================= + + +class TestPrepBackendGripper(unittest.IsolatedAsyncioTestCase): + """CORE gripper: tool lifecycle, plate geometry, drop with/without return.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck( + with_core_grippers=True + ) + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + def _make_pickup(self, resource, pickup_distance_from_top=5.0): + return ResourcePickup( + resource=resource, + offset=Coordinate.zero(), + pickup_distance_from_top=pickup_distance_from_top, + direction=GripDirection.FRONT, + ) + + def _make_drop(self, resource, destination: Coordinate, pickup_distance_from_top=5.0): + return ResourceDrop( + resource=resource, + destination=destination, + destination_absolute_rotation=Rotation(), + offset=Coordinate.zero(), + pickup_distance_from_top=pickup_distance_from_top, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.FRONT, + rotation=0.0, + ) + + def _make_move(self, resource, location: Coordinate, pickup_distance_from_top=5.0): + return ResourceMove( + resource=resource, + location=location, + gripped_direction=GripDirection.FRONT, + pickup_distance_from_top=pickup_distance_from_top, + offset=Coordinate.zero(), + ) + + async def test_auto_picks_up_tool_before_plate(self): + """When _gripper_tool_on=False, PrepPickUpTool is sent before PrepPickUpPlate.""" + self.assertFalse(self.backend._gripper_tool_on) + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + tool_cmds = _get_commands(self.mock_send, PrepPickUpTool) + plate_cmds = _get_commands(self.mock_send, PrepPickUpPlate) + self.assertEqual(len(tool_cmds), 1) + self.assertEqual(len(plate_cmds), 1) + # Tool must be picked up before plate + all_calls = [c.args[0] for c in self.mock_send.call_args_list] + self.assertLess(all_calls.index(tool_cmds[0]), all_calls.index(plate_cmds[0])) + self.assertTrue(self.backend._gripper_tool_on) + + async def test_skip_tool_pickup_when_already_holding(self): + self.backend._gripper_tool_on = True + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpPlate)), 1) + + async def test_plate_dimensions_from_resource(self): + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + cmd = _get_commands(self.mock_send, PrepPickUpPlate)[0] + self.assertAlmostEqual(cmd.plate.length, self.plate.get_absolute_size_x(), places=3) + self.assertAlmostEqual(cmd.plate.width, self.plate.get_absolute_size_y(), places=3) + self.assertAlmostEqual(cmd.plate.height, self.plate.get_absolute_size_z(), places=3) + + async def test_grip_distance_is_clearance_plus_squeeze(self): + clearance_y = 2.5 + squeeze_mm = 2.0 + await self.backend.pick_up_resource( + self._make_pickup(self.plate), + clearance_y=clearance_y, + squeeze_mm=squeeze_mm, + ) + cmd = _get_commands(self.mock_send, PrepPickUpPlate)[0] + self.assertAlmostEqual(cmd.grip_distance, clearance_y + squeeze_mm) + + async def test_grip_direction_not_front_raises(self): + pickup = ResourcePickup( + resource=self.plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.LEFT, + ) + with self.assertRaises(NotImplementedError): + await self.backend.pick_up_resource(pickup) + + async def test_drop_resource_with_return_gripper(self): + self.backend._gripper_tool_on = True + plate_loc = self.plate.get_absolute_location() + drop = self._make_drop(self.plate, destination=plate_loc) + await self.backend.drop_resource(drop, return_gripper=True) + drop_plate_cmds = _get_commands(self.mock_send, PrepDropPlate) + drop_tool_cmds = _get_commands(self.mock_send, PrepDropTool) + self.assertEqual(len(drop_plate_cmds), 1) + self.assertEqual(len(drop_tool_cmds), 1) + self.assertFalse(self.backend._gripper_tool_on) + + async def test_drop_resource_without_return_gripper(self): + self.backend._gripper_tool_on = True + plate_loc = self.plate.get_absolute_location() + drop = self._make_drop(self.plate, destination=plate_loc) + await self.backend.drop_resource(drop, return_gripper=False) + self.assertEqual(len(_get_commands(self.mock_send, PrepDropTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepDropPlate)), 1) + + async def test_move_picked_up_resource(self): + self.backend._gripper_tool_on = True + dest = Coordinate(100.0, 50.0, 10.0) + move = self._make_move(self.plate, location=dest) + await self.backend.move_picked_up_resource(move) + cmds = _get_commands(self.mock_send, PrepMovePlate) + self.assertEqual(len(cmds), 1) + + +# ============================================================================= +# 8. Convenience methods +# ============================================================================= + + +class TestPrepBackendConvenience(unittest.IsolatedAsyncioTestCase): + """Convenience methods: correct command type to correct interface address.""" + + async def asyncSetUp(self): + self.backend = _setup_backend() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] + + async def test_park(self): + await self.backend.park() + cmds = _get_commands(self.mock_send, PrepPark) + self.assertEqual(len(cmds), 1) + self.assertEqual(cmds[0].dest, _MLPREP_ADDR) + + async def test_spread(self): + await self.backend.spread() + cmds = _get_commands(self.mock_send, PrepSpread) + self.assertEqual(len(cmds), 1) + self.assertEqual(cmds[0].dest, _MLPREP_ADDR) + + async def test_method_begin_automatic_pause(self): + await self.backend.method_begin(automatic_pause=True) + cmds = _get_commands(self.mock_send, PrepMethodBegin) + self.assertEqual(len(cmds), 1) + self.assertTrue(cmds[0].automatic_pause) + + async def test_method_begin_no_automatic_pause(self): + await self.backend.method_begin(automatic_pause=False) + cmds = _get_commands(self.mock_send, PrepMethodBegin) + self.assertFalse(cmds[0].automatic_pause) + + async def test_method_end(self): + await self.backend.method_end() + self.assertEqual(len(_get_commands(self.mock_send, PrepMethodEnd)), 1) + + async def test_method_abort(self): + await self.backend.method_abort() + self.assertEqual(len(_get_commands(self.mock_send, PrepMethodAbort)), 1) + + async def test_move_to_position(self): + await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0]) + cmds = _get_commands(self.mock_send, PrepMoveToPosition) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertAlmostEqual(cmd.move_parameters.gantry_x_position, 100.0) + self.assertEqual(len(cmd.move_parameters.axis_parameters), 1) + self.assertEqual(cmd.move_parameters.axis_parameters[0].channel, ChannelIndex.RearChannel) + self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].y_position, 50.0) + self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].z_position, 20.0) + + async def test_move_to_position_via_lane(self): + await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0], via_lane=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepMoveToPositionViaLane)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepMoveToPosition)), 0) + + async def test_move_channels_to_safe_z_all(self): + await self.backend.move_channels_to_safe_z() + cmds = _get_commands(self.mock_send, PrepMoveZUpToSafe) + self.assertEqual(len(cmds), 1) + channels = cmds[0].channels + self.assertIn(ChannelIndex.RearChannel, channels) + self.assertIn(ChannelIndex.FrontChannel, channels) + + async def test_set_deck_light(self): + await self.backend.set_deck_light(white=100, red=50, green=25, blue=200) + cmds = _get_commands(self.mock_send, PrepSetDeckLight) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertEqual(cmd.white, 100) + self.assertEqual(cmd.red, 50) + self.assertEqual(cmd.green, 25) + self.assertEqual(cmd.blue, 200) + self.assertEqual(cmd.dest, _MLPREP_ADDR) + + async def test_not_implemented_96_ops(self): + from pylabrobot.liquid_handling.standard import ( + DropTipRack, MultiHeadAspirationPlate, MultiHeadDispensePlate, PickupTipRack, + ) + with self.assertRaises(NotImplementedError): + await self.backend.pick_up_tips96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.drop_tips96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.aspirate96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.dispense96(None) # type: ignore[arg-type] + + +if __name__ == "__main__": + unittest.main() From a8a605913a6c295b7e5091cc4130423f17c4238b Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:04:09 -0700 Subject: [PATCH 26/42] Power Down Commands --- .../backends/hamilton/prep_backend.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index bc36b98c008..b548844d318 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1530,6 +1530,18 @@ async def method_abort(self) -> None: """Abort the current method.""" await self.client.send_command(PrepMethodAbort(dest=await self._require("mlprep"))) + async def power_down_request(self) -> None: + """Request power down (instrument will prepare for shutdown; use cancel_power_down to abort).""" + await self.client.send_command(PrepPowerDownRequest(dest=await self._require("mlprep"))) + + async def confirm_power_down(self) -> None: + """Confirm power down (completes shutdown; only call when safe to power off).""" + await self.client.send_command(PrepConfirmPowerDown(dest=await self._require("mlprep"))) + + async def cancel_power_down(self) -> None: + """Cancel a pending power-down request.""" + await self.client.send_command(PrepCancelPowerDown(dest=await self._require("mlprep"))) + async def get_deck_light(self) -> Tuple[int, int, int, int]: """Get the current deck LED colour (white, red, green, blue).""" result = await self.client.send_command( From 072407b162373ca004e4851a6b84ef66efe25fae Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:43:05 -0700 Subject: [PATCH 27/42] Formatting. Move Demo Notebooks to Docs --- .../hamilton-nimbus/nimbus_basic_demo.ipynb | 434 ++ ...id_gripper.ipynb => prep_basic_demo.ipynb} | 11 +- .../nimbus_aspirate_dispense_demo.ipynb | 762 ---- .../backends/hamilton/nimbus_backend.py | 6 +- .../backends/hamilton/nimbus_backend_tests.py | 7 +- .../liquid_handling/backends/hamilton/prep.py | 3560 ----------------- .../backends/hamilton/prep_backend.py | 567 +-- .../backends/hamilton/prep_backend_tests.py | 369 +- .../backends/hamilton/prep_commands.py | 25 +- .../backends/hamilton/prep_tests.py | 267 -- .../backends/hamilton/tcp/introspection.py | 149 +- .../backends/hamilton/tcp/messages.py | 56 +- .../backends/hamilton/tcp/tcp_tests.py | 11 +- .../backends/hamilton/tcp_backend.py | 19 +- .../resources/hamilton/hamilton_decks.py | 8 +- 15 files changed, 1115 insertions(+), 5136 deletions(-) create mode 100644 docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb rename docs/user_guide/00_liquid-handling/hamilton-prep/{prep_demo_teaching_liquid_gripper.ipynb => prep_basic_demo.ipynb} (99%) delete mode 100644 nimbus-dev/nimbus_aspirate_dispense_demo.ipynb delete mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep.py delete mode 100644 pylabrobot/liquid_handling/backends/hamilton/prep_tests.py diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb new file mode 100644 index 00000000000..542b6e1a3d9 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb @@ -0,0 +1,434 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nimbus Aspirate and Dispense Demo\n", + "\n", + "This notebook demonstrates aspirate and dispense operations with the Hamilton Nimbus backend.\n", + "\n", + "The demo covers:\n", + "1. Creating a Nimbus Deck and assigning resources\n", + "2. Setting up the NimbusBackend and LiquidHandler\n", + "3. Picking up tips from the tip rack\n", + "4. Aspirating 50 µL from wells (2mm above bottom)\n", + "5. Dispensing to wells (2mm above bottom)\n", + "6. Dropping tips to waste\n", + "7. Cleaning up and closing the connection\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Deck created: deck\n", + " Size: 831.85 x 424.18 x 300.0 mm\n", + " Rails: 30\n", + "\n", + "Tip rack assigned: Tips\n", + "Wellplate assigned: Plate\n", + " Waste block: default_long_block\n", + "Websocket server started at http://127.0.0.1:2121\n", + "File server started at http://127.0.0.1:1337 . Open this URL in your browser.\n" + ] + } + ], + "source": [ + "# Import necessary modules\n", + "import sys\n", + "import logging\n", + "\n", + "from pylabrobot.liquid_handling import LiquidHandler\n", + "from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import NimbusBackend\n", + "from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck\n", + "from pylabrobot.resources import (\n", + " hamilton_96_tiprack_300uL_filter,\n", + " Cor_Axy_96_wellplate_500uL_Ub,\n", + " Coordinate,\n", + ")\n", + "from pylabrobot.visualizer import Visualizer\n", + "\n", + "\n", + "# Setup logging\n", + "plr_logger = logging.getLogger('pylabrobot')\n", + "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", + "plr_logger.handlers.clear()\n", + "console_handler = logging.StreamHandler(sys.stdout)\n", + "console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))\n", + "plr_logger.addHandler(console_handler)\n", + "\n", + "# ========================================================================\n", + "# CREATE DECK AND RESOURCES (using coordinates from nimbus_deck_setup.ipynb)\n", + "# ========================================================================\n", + "\n", + "# Create NimbusDeck using default values (layout 8 dimensions)\n", + "deck = NimbusDeck()\n", + "\n", + "print(f\"Deck created: {deck.name}\")\n", + "print(f\" Size: {deck.get_size_x()} x {deck.get_size_y()} x {deck.get_size_z()} mm\")\n", + "print(f\" Rails: {deck.num_rails}\")\n", + "\n", + "tip_rack = hamilton_96_tiprack_300uL_filter(name=\"Tips\", with_tips=True)\n", + "deck.assign_child_resource(tip_rack, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", + "\n", + "print(f\"\\nTip rack assigned: {tip_rack.name}\")\n", + "wellplate = Cor_Axy_96_wellplate_500uL_Ub(name=\"Plate\", with_lid=False)\n", + "deck.assign_child_resource(wellplate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", + "\n", + "print(f\"Wellplate assigned: {wellplate.name}\")\n", + "print(f\" Waste block: {deck.get_resource('default_long_block').name}\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LiquidHandler created successfully\n", + "INFO - Connection initialized (Client ID: 3, Address: 2:3:65535)\n", + "INFO - Client registered.\n", + "INFO - Setup complete. Registered as Client ID 3 (2:3:65535), Root: NimbusCORE\n", + "INFO - Interfaces: door_lock, nimbus_core, pipette\n", + "INFO - Channel configuration: 4 channels\n", + "INFO - Tip presence: [False, False, False, False]\n", + "INFO - Instrument initialized: True\n", + "INFO - Door already locked\n", + "INFO - Instrument already initialized, skipping initialization\n", + "\n", + "============================================================\n", + "SETUP COMPLETE\n", + "============================================================\n", + "Setup finished: True\n", + "\n", + "Instrument Configuration:\n", + " Number of channels: 4\n" + ] + } + ], + "source": [ + "# Create NimbusBackend instance\n", + "# Replace with your instrument's IP address\n", + "backend = NimbusBackend(host=\"192.168.100.100\", port=2000)\n", + "# Create LiquidHandler with backend and deck\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "print(\"LiquidHandler created successfully\")\n", + "\n", + "# Setup\n", + "await lh.setup(unlock_door=False)\n", + "print(\"\\n\" + \"=\"*60)\n", + "print(\"SETUP COMPLETE\")\n", + "print(\"=\"*60)\n", + "print(f\"Setup finished: {backend.setup_finished}\")\n", + "print(f\"\\nInstrument Configuration:\")\n", + "print(f\" Number of channels: {backend.num_channels}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define Resources" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tip rack: Tips (96 tips)\n", + "Source/Destination plate: Plate (using same plate, different wells)\n", + "Waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n" + ] + } + ], + "source": [ + "# Resources are already created in the setup cell above\n", + "# tip_rack and wellplate variables are available\n", + "\n", + "print(f\"Tip rack: {tip_rack.name} ({tip_rack.num_items} tips)\")\n", + "print(f\"Source/Destination plate: {wellplate.name} (using same plate, different wells)\")\n", + "\n", + "# Use wellplate as both source and destination\n", + "source_plate = wellplate\n", + "destination_plate = wellplate\n", + "\n", + "# Get waste positions\n", + "waste_block = deck.get_resource(\"default_long_block\")\n", + "waste_positions = waste_block.children[:4]\n", + "\n", + "print(f\"Waste positions: {[wp.name for wp in waste_positions]}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick Up Tips\n", + "\n", + "Pick up tips from positions A1-D1.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up tips from positions: ['A1', 'B1', 'C1', 'D1']\n", + "DEBUG - pick_up_tips(tip_spots=['Tips_tipspot_A1', 'Tips_tipspot_B1', 'Tips_tipspot_C1', 'Tips_tipspot_D1'], use_channels=None, offsets=None)\n", + "DEBUG - IsTipPresent parameters: {}\n", + "DEBUG - PickupTips parameters: {'channels_involved': [1, 1, 1, 1], 'x_positions': [16144, 16144, 16144, 16144], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'begin_tip_pick_up_process': [13802, 13802, 13802, 13802], 'end_tip_pick_up_process': [13002, 13002, 13002, 13002], 'tip_types': [, , , ]}\n", + "INFO - Picked up tips on channels [0, 1, 2, 3]\n", + "✓ Tips picked up successfully!\n" + ] + } + ], + "source": [ + "plr_logger.setLevel(logging.DEBUG) # INFO for normal use, DEBUG for troubleshooting\n", + "# Get the first 4 tip spots (A1, B1, C1, D1)\n", + "tip_spots = tip_rack['A1:D1']\n", + "\n", + "print(f\"Picking up tips from positions: {[ts.get_identifier() for ts in tip_spots]}\")\n", + "await lh.pick_up_tips(tip_spots)\n", + "print(\"✓ Tips picked up successfully!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aspirate Operation\n", + "\n", + "Aspirate 50 µL from wells A1-D1, 2mm above the bottom of the well.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Aspirating 50 µL from wells: ['A1', 'B1', 'C1', 'D1']\n", + " Liquid height: 2.0 mm above bottom\n", + "DEBUG - aspirate(resources=['Plate_well_A1', 'Plate_well_B1', 'Plate_well_C1', 'Plate_well_D1'], vols=[50, 50, 50, 50], use_channels=None, flow_rates=[100, 100, 100, 100], offsets=None, liquid_height=[2, 2, 2, 2], blow_out_air_volume=None)\n", + "DEBUG - DisableADC parameters: {'channels_involved': [1, 1, 1, 1]}\n", + "INFO - Disabled ADC before aspirate\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 1, 'indexes': [2]}\n", + "DEBUG - Channel 1 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 2, 'indexes': [2]}\n", + "DEBUG - Channel 2 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 3, 'indexes': [2]}\n", + "DEBUG - Channel 3 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 4, 'indexes': [2]}\n", + "DEBUG - Channel 4 configuration (index 2): enabled=False\n", + "DEBUG - Aspirate parameters: {'aspirate_type': [0, 0, 0, 0], 'channels_involved': [1, 1, 1, 1], 'x_positions': [29616, 29616, 29616, 29616], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'lld_search_height': [1225, 1225, 1225, 1225], 'liquid_height': [10587, 10587, 10587, 10587], 'immersion_depth': [0, 0, 0, 0], 'surface_following_distance': [0, 0, 0, 0], 'minimum_height': [10387, 10387, 10387, 10387], 'clot_detection_height': [0, 0, 0, 0], 'min_z_endpos': 14600, 'swap_speed': [200, 200, 200, 200], 'blow_out_air_volume': [400, 400, 400, 400], 'pre_wetting_volume': [0, 0, 0, 0], 'aspirate_volume': [500, 500, 500, 500], 'transport_air_volume': [50, 50, 50, 50], 'aspiration_speed': [1000, 1000, 1000, 1000], 'settling_time': [10, 10, 10, 10], 'mix_volume': [0, 0, 0, 0], 'mix_cycles': [0, 0, 0, 0], 'mix_position_from_liquid_surface': [0, 0, 0, 0], 'mix_surface_following_distance': [0, 0, 0, 0], 'mix_speed': [1000, 1000, 1000, 1000], 'tube_section_height': [0, 0, 0, 0], 'tube_section_ratio': [0, 0, 0, 0], 'lld_mode': [0, 0, 0, 0], 'gamma_lld_sensitivity': [0, 0, 0, 0], 'dp_lld_sensitivity': [0, 0, 0, 0], 'lld_height_difference': [0, 0, 0, 0], 'tadm_enabled': False, 'limit_curve_index': [0, 0, 0, 0], 'recording_mode': 0}\n", + "INFO - Aspirated on channels [0, 1, 2, 3]\n", + "✓ Aspiration complete!\n" + ] + } + ], + "source": [ + "# Get source wells (A1, B1, C1, D1)\n", + "source_wells = source_plate['A1:D1']\n", + "\n", + "print(f\"Aspirating 50 µL from wells: {[w.get_identifier() for w in source_wells]}\")\n", + "print(f\" Liquid height: 2.0 mm above bottom\")\n", + "\n", + "# Aspirate with liquid_height=2.0mm\n", + "# Tips are already picked up, so LiquidHandler will use them automatically\n", + "await lh.aspirate(\n", + " source_wells,\n", + " vols = 4 *[50], # Can be a single number (applies to all channels) or a list\n", + " liquid_height = 4 *[2], # 2mm above bottom of well (can be a single float or list)\n", + " flow_rates = 4 *[100],\n", + ")\n", + "\n", + "print(\"✓ Aspiration complete!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dispense Operation\n", + "\n", + "Dispense 50 µL to wells A2-D2, 2mm above the bottom of the well.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dispensing 50 µL to wells: ['A7', 'B7', 'C7', 'D7']\n", + " Liquid height: 2.0 mm above bottom\n", + "DEBUG - dispense(resources=['Plate_well_A7', 'Plate_well_B7', 'Plate_well_C7', 'Plate_well_D7'], vols=[50, 50, 50, 50], use_channels=None, flow_rates=[120, 120, 120, 120], offsets=None, liquid_height=[2, 2, 2, 2], blow_out_air_volume=None)\n", + "DEBUG - DisableADC parameters: {'channels_involved': [1, 1, 1, 1]}\n", + "INFO - Disabled ADC before dispense\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 1, 'indexes': [2]}\n", + "DEBUG - Channel 1 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 2, 'indexes': [2]}\n", + "DEBUG - Channel 2 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 3, 'indexes': [2]}\n", + "DEBUG - Channel 3 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 4, 'indexes': [2]}\n", + "DEBUG - Channel 4 configuration (index 2): enabled=False\n", + "DEBUG - Dispense parameters: {'dispense_type': [0, 0, 0, 0], 'channels_involved': [1, 1, 1, 1], 'x_positions': [35016, 35016, 35016, 35016], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'lld_search_height': [1225, 1225, 1225, 1225], 'liquid_height': [10587, 10587, 10587, 10587], 'immersion_depth': [0, 0, 0, 0], 'surface_following_distance': [0, 0, 0, 0], 'minimum_height': [10387, 10387, 10387, 10387], 'min_z_endpos': 14600, 'swap_speed': [200, 200, 200, 200], 'transport_air_volume': [50, 50, 50, 50], 'dispense_volume': [500, 500, 500, 500], 'stop_back_volume': [0, 0, 0, 0], 'blow_out_air_volume': [400, 400, 400, 400], 'dispense_speed': [1200, 1200, 1200, 1200], 'cut_off_speed': [250, 250, 250, 250], 'settling_time': [10, 10, 10, 10], 'mix_volume': [0, 0, 0, 0], 'mix_cycles': [0, 0, 0, 0], 'mix_position_from_liquid_surface': [0, 0, 0, 0], 'mix_surface_following_distance': [0, 0, 0, 0], 'mix_speed': [1200, 1200, 1200, 1200], 'side_touch_off_distance': 0, 'dispense_offset': [0, 0, 0, 0], 'tube_section_height': [0, 0, 0, 0], 'tube_section_ratio': [0, 0, 0, 0], 'lld_mode': [0, 0, 0, 0], 'gamma_lld_sensitivity': [0, 0, 0, 0], 'tadm_enabled': False, 'limit_curve_index': [0, 0, 0, 0], 'recording_mode': 0}\n", + "INFO - Dispensed on channels [0, 1, 2, 3]\n", + "✓ Dispense complete!\n" + ] + } + ], + "source": [ + "# Get destination wells (A2, B2, C2, D2)\n", + "dest_wells = destination_plate['A7:D7']\n", + "\n", + "print(f\"Dispensing 50 µL to wells: {[w.get_identifier() for w in dest_wells]}\")\n", + "print(f\" Liquid height: 2.0 mm above bottom\")\n", + "\n", + "# Dispense with liquid_height=2.0mm\n", + "# Tips are already picked up, so LiquidHandler will use them automatically\n", + "await lh.dispense(\n", + " dest_wells,\n", + " vols= 4 *[50], # Can be a single number (applies to all channels) or a list\n", + " liquid_height= 4 *[2], # 2mm above bottom of well (can be a single float or list)\n", + " flow_rates= 4 *[120],\n", + ")\n", + "\n", + "print(\"✓ Dispense complete!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop Tips\n", + "\n", + "Drop tips to waste positions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dropping tips at waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n", + "DEBUG - drop_tips(tip_spots=['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4'], use_channels=None, offsets=None, allow_nonzero_volume=False)\n", + "DEBUG - DropTipsRoll parameters: {'channels_involved': [1, 1, 1, 1], 'x_positions': [55375, 55375, 55375, 55375], 'y_positions': [1986, 188, -7615, -9413], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'begin_tip_deposit_process': [13539, 13539, 13539, 13539], 'end_tip_deposit_process': [13139, 13139, 13139, 13139], 'z_position_at_end_of_a_command': [14600, 14600, 14600, 14600], 'roll_distances': [900, 900, 900, 900]}\n", + "INFO - Dropped tips on channels [0, 1, 2, 3]\n", + "✓ Tips dropped successfully!\n" + ] + } + ], + "source": [ + "print(f\"Dropping tips at waste positions: {[wp.name for wp in waste_positions]}\")\n", + "await lh.drop_tips(waste_positions)\n", + "\n", + "print(\"✓ Tips dropped successfully!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Finally, we'll stop the liquid handler and close the connection.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Instrument parked successfully\n", + "INFO - Door unlocked successfully\n", + "INFO - Closing connection to socket 192.168.100.100:2000\n", + "INFO - Hamilton TCP client stopped\n", + "Connection closed successfully\n" + ] + } + ], + "source": [ + "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", + "\n", + "# Stop and close connection\n", + "await lh.backend.park()\n", + "await lh.backend.unlock_door()\n", + "await lh.stop()\n", + "\n", + "print(\"Connection closed successfully\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb similarity index 99% rename from docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb rename to docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb index 57810b7b93f..4d79df3b057 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_demo_teaching_liquid_gripper.ipynb +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -121,7 +121,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "9c4f5771", "metadata": {}, "outputs": [ @@ -154,7 +154,6 @@ ], "source": [ "# Deck, waste, and other config data from setup can be accessed here.\n", - "\n", "config = lh.backend._config\n", "print(\"Instrument configuration:\")\n", "print(f\" Deck bounds: {config.deck_bounds}\")\n", @@ -404,14 +403,6 @@ "await lh.backend.disco_mode()\n", "await lh.stop()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f53fe787", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb b/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb deleted file mode 100644 index a9b8e7e658f..00000000000 --- a/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb +++ /dev/null @@ -1,762 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Nimbus Aspirate and Dispense Demo\n", - "\n", - "This notebook demonstrates aspirate and dispense operations with the Hamilton Nimbus backend.\n", - "\n", - "The demo covers:\n", - "1. Creating a Nimbus Deck and assigning resources\n", - "2. Setting up the NimbusBackend and LiquidHandler\n", - "3. Picking up tips from the tip rack\n", - "4. Aspirating 50 µL from wells (2mm above bottom)\n", - "5. Dispensing to wells (2mm above bottom)\n", - "6. Dropping tips to waste\n", - "7. Cleaning up and closing the connection\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Deck created: deck\n", - " Size: 831.85 x 424.18 x 300.0 mm\n", - " Rails: 30\n", - "\n", - "Tip rack assigned: HAM_FTR_300_0001\n", - "Wellplate assigned: Cor_96_wellplate_2mL_Vb_0001\n", - " Waste block: default_long_block\n", - "LiquidHandler created successfully\n", - "INFO - Connecting to TCP server 192.168.100.100:2000...\n", - "INFO - Connected to TCP server 192.168.100.100:2000\n", - "INFO - Initializing Hamilton connection...\n", - "INFO - [INIT] Sending Protocol 7 initialization packet:\n", - "INFO - [INIT] Length: 28 bytes\n", - "INFO - [INIT] Hex: 1a 00 07 30 00 00 00 00 03 00 01 10 00 00 00 00 02 10 00 00 01 00 04 10 00 00 1e 00\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - [INIT] Received response:\n", - "INFO - [INIT] Length: 28 bytes\n", - "INFO - [INIT] Hex: 1a 00 07 30 00 00 00 00 03 00 01 11 00 00 02 00 02 11 07 00 01 00 04 11 00 00 1e 00\n", - "INFO - [INIT] ✓ Client ID: 2, Address: 2:2:65535\n", - "INFO - Registering Hamilton client...\n", - "INFO - [REGISTER] Sending registration packet:\n", - "INFO - [REGISTER] Length: 48 bytes, Seq: 1\n", - "INFO - [REGISTER] Hex: 2e 00 06 30 00 00 02 00 02 00 ff ff 00 00 00 00 fe ff 01 00 03 03 2a 00 00 00 00 00 00 00 00 00 00 00 02 00 02 00 ff ff 00 00 00 00 00 00 00 00\n", - "INFO - [REGISTER] Src: 2:2:65535, Dst: 0:0:65534\n", - "INFO - [REGISTER] Received response:\n", - "INFO - [REGISTER] Length: 48 bytes\n", - "INFO - [REGISTER] ✓ Registration complete\n", - "INFO - Discovering Hamilton root objects...\n", - "INFO - [DISCOVER_ROOT] Sending root object discovery:\n", - "INFO - [DISCOVER_ROOT] Length: 52 bytes, Seq: 2\n", - "INFO - [DISCOVER_ROOT] Hex: 32 00 06 30 00 00 02 00 02 00 ff ff 00 00 00 00 fe ff 02 00 03 13 2e 00 00 00 00 00 0c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 05 02 02 01\n", - "INFO - [DISCOVER_ROOT] ✓ Found 1 root objects\n", - "INFO - ✓ Discovery complete: 1 root objects\n", - "INFO - Hamilton backend setup complete. Client ID: 2\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 0\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:259\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 1\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:263\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 2\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:768\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 3\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:260\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 4\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:257\n", - "INFO - Found Pipette at 1:1:257\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 5\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:262\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 6\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:261\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 7\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:265\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 8\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:266\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 9\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:258\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 10\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:48880\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 11\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:270\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 12\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:271\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 13\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:269\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 14\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:384\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 15\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49152\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 16\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49408\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 17\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:272\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 18\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49409\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 19\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:273\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 20\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49410\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 21\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:274\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 22\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49411\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 23\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:275\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 24\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:264\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 25\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:268\n", - "INFO - Found DoorLock at 1:1:268\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 26\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:128:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 27\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:129:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 28\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:96:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 29\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:32:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 30\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 96:1:48896\n", - "INFO - GetChannelConfiguration_1 parameters:\n", - "INFO - Channel configuration: 4 channels\n", - "INFO - IsTipPresent parameters:\n", - "INFO - Tip presence: [0, 0, 0, 0]\n", - "INFO - IsInitialized parameters:\n", - "INFO - Instrument initialized: False\n", - "INFO - IsDoorLocked parameters:\n", - "INFO - LockDoor parameters:\n", - "INFO - Door locked successfully\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - Channel configuration set for 4 channels\n", - "INFO - InitializeSmartRoll parameters:\n", - "INFO - x_positions: [55375, 55375, 55375, 55375]\n", - "INFO - y_positions: [1986, 188, -7615, -9413]\n", - "INFO - z_start_positions: [13539, 13539, 13539, 13539]\n", - "INFO - z_stop_positions: [13139, 13139, 13139, 13139]\n", - "INFO - z_final_positions: [14600, 14600, 14600, 14600]\n", - "INFO - roll_distances: [900, 900, 900, 900]\n", - "INFO - NimbusCore initialized with InitializeSmartRoll successfully\n", - "\n", - "============================================================\n", - "SETUP COMPLETE\n", - "============================================================\n", - "Setup finished: True\n", - "\n", - "Instrument Configuration:\n", - " Number of channels: 4\n" - ] - } - ], - "source": [ - "# Import necessary modules\n", - "import sys\n", - "import logging\n", - "\n", - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import NimbusBackend\n", - "from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck\n", - "from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL_filter\n", - "from pylabrobot.resources.corning import Cor_96_wellplate_2mL_Vb\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "\n", - "# Setup logging\n", - "plr_logger = logging.getLogger('pylabrobot')\n", - "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", - "plr_logger.handlers.clear()\n", - "console_handler = logging.StreamHandler(sys.stdout)\n", - "console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))\n", - "plr_logger.addHandler(console_handler)\n", - "\n", - "# ========================================================================\n", - "# CREATE DECK AND RESOURCES (using coordinates from nimbus_deck_setup.ipynb)\n", - "# ========================================================================\n", - "\n", - "# Create NimbusDeck using default values (layout 8 dimensions)\n", - "deck = NimbusDeck()\n", - "\n", - "print(f\"Deck created: {deck.name}\")\n", - "print(f\" Size: {deck.get_size_x()} x {deck.get_size_y()} x {deck.get_size_z()} mm\")\n", - "print(f\" Rails: {deck.num_rails}\")\n", - "\n", - "# Create and assign tip rack (HAM_FTR_300_0001)\n", - "# Using pre-calculated origin from nimbus_deck_setup.ipynb output:\n", - "# Tip rack origin (PyLabRobot): Coordinate(305.750, 126.537, 128.620)\n", - "tip_rack = hamilton_96_tiprack_300uL_filter(name=\"HAM_FTR_300_0001\", with_tips=True)\n", - "deck.assign_child_resource(tip_rack, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", - "\n", - "print(f\"\\nTip rack assigned: {tip_rack.name}\")\n", - "\n", - "# Create and assign wellplate (Cor_96_wellplate_2mL_Vb_0001)\n", - "# Using pre-calculated origin from nimbus_deck_setup.ipynb output:\n", - "# Wellplate origin (PyLabRobot): Coordinate(438.070, 124.837, 101.490)\n", - "wellplate = Cor_96_wellplate_2mL_Vb(name=\"Cor_96_wellplate_2mL_Vb_0001\", with_lid=False)\n", - "deck.assign_child_resource(wellplate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", - "\n", - "print(f\"Wellplate assigned: {wellplate.name}\")\n", - "print(f\" Waste block: {deck.get_resource('default_long_block').name}\")\n", - "\n", - "# Serialize the deck #\n", - "#serialized = deck.serialize()\n", - "#with open(\"test_nimbus_deck.json\", \"w\") as f:\n", - "# json.dump(serialized, f, indent=2)\n", - "\n", - "# Load from file and deserialize\n", - "#with open(\"test_nimbus_deck.json\", \"r\") as f:\n", - "# deck_data = json.load(f)\n", - "# Read deck from file example\n", - "# loaded_deck = NimbusDeck.deserialize(deck_data)\n", - "\n", - "# Create NimbusBackend instance\n", - "# Replace with your instrument's IP address\n", - "backend = NimbusBackend(\n", - " host=\"192.168.100.100\", # Replace with your instrument's IP\n", - " port=2000,\n", - " read_timeout=30,\n", - " write_timeout=30\n", - ")\n", - "\n", - "# Create LiquidHandler with backend and deck\n", - "lh = LiquidHandler(backend=backend, deck=deck)\n", - "\n", - "print(\"LiquidHandler created successfully\")\n", - "\n", - "# Setup the robot\n", - "await lh.setup(unlock_door=False)\n", - "\n", - "print(\"\\n\" + \"=\"*60)\n", - "print(\"SETUP COMPLETE\")\n", - "print(\"=\"*60)\n", - "print(f\"Setup finished: {backend.setup_finished}\")\n", - "print(f\"\\nInstrument Configuration:\")\n", - "print(f\" Number of channels: {backend.num_channels}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Define Resources" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Tip rack: HAM_FTR_300_0001 (96 tips)\n", - "Source/Destination plate: Cor_96_wellplate_2mL_Vb_0001 (using same plate, different wells)\n", - "Waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n" - ] - } - ], - "source": [ - "# Resources are already created in the setup cell above\n", - "# tip_rack and wellplate variables are available\n", - "\n", - "print(f\"Tip rack: {tip_rack.name} ({tip_rack.num_items} tips)\")\n", - "print(f\"Source/Destination plate: {wellplate.name} (using same plate, different wells)\")\n", - "\n", - "# Use wellplate as both source and destination\n", - "source_plate = wellplate\n", - "destination_plate = wellplate\n", - "\n", - "# Get waste positions\n", - "waste_block = deck.get_resource(\"default_long_block\")\n", - "waste_positions = waste_block.children[:4]\n", - "\n", - "print(f\"Waste positions: {[wp.name for wp in waste_positions]}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Pick Up Tips\n", - "\n", - "Pick up tips from positions A1-D1.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up tips from positions: ['E4', 'F4', 'G4', 'H4']\n", - "INFO - IsTipPresent parameters:\n", - "INFO - PickupTips parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [18844, 18844, 18844, 18844]\n", - "INFO - y_positions: [-20499, -21399, -22299, -23199]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13802, 13802, 13802, 13802]\n", - "INFO - z_stop_positions: [13002, 13002, 13002, 13002]\n", - "INFO - tip_types: [, , , ]\n", - "INFO - num_channels: 4\n", - "INFO - PickupTips parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [18844, 18844, 18844, 18844]\n", - "INFO - y_positions: [-20499, -21399, -22299, -23199]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13802, 13802, 13802, 13802]\n", - "INFO - z_stop_positions: [13002, 13002, 13002, 13002]\n", - "INFO - tip_types: [, , , ]\n", - "INFO - Picked up tips on channels [0, 1, 2, 3]\n", - "✓ Tips picked up successfully!\n" - ] - } - ], - "source": [ - "# Get the first 4 tip spots (A1, B1, C1, D1)\n", - "tip_spots = tip_rack[\"E4\":\"A5\"]\n", - "\n", - "print(f\"Picking up tips from positions: {[ts.get_identifier() for ts in tip_spots]}\")\n", - "await lh.pick_up_tips(tip_spots)\n", - "\n", - "print(\"✓ Tips picked up successfully!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Aspirate Operation\n", - "\n", - "Aspirate 50 µL from wells A1-D1, 2mm above the bottom of the well.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Aspirating 50 µL from wells: ['A7', 'B7', 'C7', 'D7']\n", - " Liquid height: 2.0 mm above bottom\n", - "INFO - DisableADC parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - Disabled ADC before aspirate\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [2]\n", - "INFO - Aspirate parameters:\n", - "INFO - aspirate_type: [0, 0, 0, 0]\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [35016, 35016, 35016, 35016]\n", - "INFO - y_positions: [-16899, -17799, -18699, -19599]\n", - "INFO - traverse_height: 14600\n", - "INFO - liquid_seek_height: [500, 500, 500, 500]\n", - "INFO - liquid_surface_height: [10594, 10594, 10594, 10594]\n", - "INFO - submerge_depth: [0, 0, 0, 0]\n", - "INFO - follow_depth: [0, 0, 0, 0]\n", - "INFO - z_min_position: [10394, 10394, 10394, 10394]\n", - "INFO - clot_check_height: [0, 0, 0, 0]\n", - "INFO - z_final: 14600\n", - "INFO - liquid_exit_speed: [200, 200, 200, 200]\n", - "INFO - blowout_volume: [400, 400, 400, 400]\n", - "INFO - prewet_volume: [0, 0, 0, 0]\n", - "INFO - aspirate_volume: [500, 500, 500, 500]\n", - "INFO - transport_air_volume: [50, 50, 50, 50]\n", - "INFO - aspirate_speed: [2500, 2500, 2500, 2500]\n", - "INFO - settling_time: [10, 10, 10, 10]\n", - "INFO - mix_volume: [0, 0, 0, 0]\n", - "INFO - mix_cycles: [0, 0, 0, 0]\n", - "INFO - mix_position: [0, 0, 0, 0]\n", - "INFO - mix_follow_distance: [0, 0, 0, 0]\n", - "INFO - mix_speed: [2500, 2500, 2500, 2500]\n", - "INFO - tube_section_height: [0, 0, 0, 0]\n", - "INFO - tube_section_ratio: [0, 0, 0, 0]\n", - "INFO - lld_mode: [0, 0, 0, 0]\n", - "INFO - capacitive_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - pressure_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - lld_height_difference: [0, 0, 0, 0]\n", - "INFO - tadm_enabled: False\n", - "INFO - limit_curve_index: [0, 0, 0, 0]\n", - "INFO - recording_mode: 0\n", - "INFO - Aspirated on channels [0, 1, 2, 3]\n", - "✓ Aspiration complete!\n" - ] - } - ], - "source": [ - "# Get source wells (A1, B1, C1, D1)\n", - "source_wells = source_plate[\"A7\":\"E7\"]\n", - "\n", - "print(f\"Aspirating 50 µL from wells: {[w.get_identifier() for w in source_wells]}\")\n", - "print(f\" Liquid height: 2.0 mm above bottom\")\n", - "\n", - "# Aspirate with liquid_height=2.0mm\n", - "# Tips are already picked up, so LiquidHandler will use them automatically\n", - "await lh.aspirate(\n", - " source_wells,\n", - " vols=[50.0, 50.0, 50.0, 50.0], # Can be a single number (applies to all channels) or a list\n", - " liquid_height=[2.0, 2.0, 2.0, 2.0], # 2mm above bottom of well (can be a single float or list)\n", - " flow_rates=[250.0, 250.0, 250.0, 250.0],\n", - " liquid_seek_height=[5.0, 5.0, 5.0, 5.0],\n", - ")\n", - "\n", - "print(\"✓ Aspiration complete!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Dispense Operation\n", - "\n", - "Dispense 50 µL to wells A2-D2, 2mm above the bottom of the well.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dispensing 50 µL to wells: ['A12', 'B12', 'C12', 'D12']\n", - " Liquid height: 2.0 mm above bottom\n", - "INFO - DisableADC parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - Disabled ADC before dispense\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [2]\n", - "INFO - Dispense parameters:\n", - "INFO - dispense_type: [0, 0, 0, 0]\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [39516, 39516, 39516, 39516]\n", - "INFO - y_positions: [-16899, -17799, -18699, -19599]\n", - "INFO - traverse_height: 14600\n", - "INFO - liquid_seek_height: [500, 500, 500, 500]\n", - "INFO - dispense_height: [10594, 10594, 10594, 10594]\n", - "INFO - submerge_depth: [0, 0, 0, 0]\n", - "INFO - follow_depth: [0, 0, 0, 0]\n", - "INFO - z_min_position: [10394, 10394, 10394, 10394]\n", - "INFO - z_final: 14600\n", - "INFO - liquid_exit_speed: [200, 200, 200, 200]\n", - "INFO - transport_air_volume: [50, 50, 50, 50]\n", - "INFO - dispense_volume: [500, 500, 500, 500]\n", - "INFO - stop_back_volume: [0, 0, 0, 0]\n", - "INFO - blowout_volume: [400, 400, 400, 400]\n", - "INFO - dispense_speed: [4000, 4000, 4000, 4000]\n", - "INFO - cutoff_speed: [250, 250, 250, 250]\n", - "INFO - settling_time: [10, 10, 10, 10]\n", - "INFO - mix_volume: [0, 0, 0, 0]\n", - "INFO - mix_cycles: [0, 0, 0, 0]\n", - "INFO - mix_position: [0, 0, 0, 0]\n", - "INFO - mix_follow_distance: [0, 0, 0, 0]\n", - "INFO - mix_speed: [4000, 4000, 4000, 4000]\n", - "INFO - touch_off_distance: 0\n", - "INFO - dispense_offset: [0, 0, 0, 0]\n", - "INFO - tube_section_height: [0, 0, 0, 0]\n", - "INFO - tube_section_ratio: [0, 0, 0, 0]\n", - "INFO - lld_mode: [0, 0, 0, 0]\n", - "INFO - capacitive_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - tadm_enabled: False\n", - "INFO - limit_curve_index: [0, 0, 0, 0]\n", - "INFO - recording_mode: 0\n", - "INFO - Dispensed on channels [0, 1, 2, 3]\n", - "✓ Dispense complete!\n" - ] - } - ], - "source": [ - "# Get destination wells (A2, B2, C2, D2)\n", - "dest_wells = destination_plate[\"A12\":\"E12\"]\n", - "\n", - "print(f\"Dispensing 50 µL to wells: {[w.get_identifier() for w in dest_wells]}\")\n", - "print(f\" Liquid height: 2.0 mm above bottom\")\n", - "\n", - "# Dispense with liquid_height=2.0mm\n", - "# Tips are already picked up, so LiquidHandler will use them automatically\n", - "await lh.dispense(\n", - " dest_wells,\n", - " vols=[50.0, 50.0, 50.0, 50.0], # Can be a single number (applies to all channels) or a list\n", - " liquid_height=[2.0, 2.0, 2.0, 2.0], # 2mm above bottom of well (can be a single float or list)\n", - " flow_rates=[400.0, 400.0, 400.0, 400.0],\n", - " liquid_seek_height=[5.0, 5.0, 5.0, 5.0],\n", - ")\n", - "\n", - "print(\"✓ Dispense complete!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Drop Tips\n", - "\n", - "Drop tips to waste positions.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dropping tips at waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n", - "INFO - DropTipsRoll parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [55375, 55375, 55375, 55375]\n", - "INFO - y_positions: [1986, 188, -7615, -9413]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13539, 13539, 13539, 13539]\n", - "INFO - z_stop_positions: [13139, 13139, 13139, 13139]\n", - "INFO - z_final_positions: [14600, 14600, 14600, 14600]\n", - "INFO - roll_distances: [900, 900, 900, 900]\n", - "INFO - Dropped tips on channels [0, 1, 2, 3]\n", - "✓ Tips dropped successfully!\n" - ] - } - ], - "source": [ - "print(f\"Dropping tips at waste positions: {[wp.name for wp in waste_positions]}\")\n", - "await lh.drop_tips(waste_positions)\n", - "\n", - "print(\"✓ Tips dropped successfully!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup\n", - "\n", - "Finally, we'll stop the liquid handler and close the connection.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - Park parameters:\n", - "INFO - Instrument parked successfully\n", - "INFO - UnlockDoor parameters:\n", - "INFO - Door unlocked successfully\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:root:Closing connection to TCP server.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - Hamilton backend stopped\n", - "Connection closed successfully\n" - ] - } - ], - "source": [ - "# Stop and close connection\n", - "await lh.backend.park()\n", - "await lh.backend.unlock_door()\n", - "await lh.stop()\n", - "\n", - "print(\"Connection closed successfully\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.18" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index bb236be9125..a9a017c0071 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -452,8 +452,8 @@ class NimbusBackend(LiquidHandlerBackend): # Declare known object paths via InterfaceSpec. Optional interfaces (e.g. pipette, door_lock) may be absent on some systems. _INTERFACES: dict[str, InterfaceSpec] = { "nimbus_core": InterfaceSpec("NimbusCORE", True, True), - "pipette": InterfaceSpec("NimbusCORE.Pipette", False, True), - "door_lock": InterfaceSpec("NimbusCORE.DoorLock", False, True), + "pipette": InterfaceSpec("NimbusCORE.Pipette", False, True), + "door_lock": InterfaceSpec("NimbusCORE.DoorLock", False, True), } @overload @@ -1199,7 +1199,7 @@ async def aspirate( settling_time: Settling time (s), default: [1.0] * n transport_air_volume: Transport air volume (uL), default: [5.0] * n pre_wetting_volume: Pre-wetting volume (uL), default: [0.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n + swap_speed: Swap speed on leaving liquid (mm/s), default: [20.0] * n mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n limit_curve_index: Limit curve index, default: [0] * n tadm_enabled: TADM enabled flag, default: False diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 28003e3c72c..528d4de4d2d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -42,7 +42,12 @@ U16, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ObjectInfo -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, +) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol, Hoi2Action from pylabrobot.liquid_handling.standard import ( Drop, diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep.py b/pylabrobot/liquid_handling/backends/hamilton/prep.py deleted file mode 100644 index bd85e2c980b..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/prep.py +++ /dev/null @@ -1,3560 +0,0 @@ -import asyncio -import random -import socket -import struct -import time -from dataclasses import dataclass -from enum import Enum, IntEnum -from typing import Any, List, Optional, Tuple, Union - -from pylabrobot.liquid_handling.backends import LiquidHandlerBackend -from pylabrobot.liquid_handling.standard import ( - SingleChannelAspiration, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - SingleChannelDispense, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Drop, - DropTipRack, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, -) - - -class ParameterTypes(Enum): - Void = 0 - Int8Bit = 1 - Int16Bit = 2 - Int32Bit = 3 - UInt8Bit = 4 - UInt16Bit = 5 - UInt32Bit = 6 - String = 15 - UInt8Array = 22 - Bool = 23 - Int8Array = 24 - Int16Array = 25 - UInt16Array = 26 - Int32Array = 27 - UInt32Array = 28 - BoolArray = 29 - Structure = 30 - StructureArray = 31 - Enum = 32 - HcResult = 33 - StringArray = 34 - EnumArray = 35 - Int64Bit = 36 - UInt64Bit = 37 - Int64Array = 38 - UInt64Array = 39 - Real32Bit = 40 - Real64Bit = 41 - Real32Array = 42 - Real64Array = 43 - - -class StructureWrapper: - def __init__(self, data=None): - self.members = [] # List of member data - self.m_member_names = [] # List of member names - - if data is not None: - offset = 0 - while offset < len(data): - fragment_length = struct.unpack_from("H", data, offset + 2)[0] - data_fragment = parse_data_fragment(data[offset:]) - - if data_fragment["format"] == ParameterTypes.EnumArray: - enumeration_wrapper_array = data_fragment["fragment_data"] - # undefined? - if enumeration_wrapper_array is None: - enumeration_wrapper_array = ["???"] - self.members.append(enumeration_wrapper_array) - else: - self.members.append(data_fragment["fragment_data"]) - - self.m_member_names.append("") - offset += fragment_length + 4 - - def encode(self): - encoded_data = b"" - for member in self.members: - encoded_data += encode_data_fragment(member["value"], member["type"]) - return encoded_data - - -# TODO: -DataFragment = dict - - -def parse_data_fragment(data: bytes) -> DataFragment: - padded_bit_field = 0x1 - parameter_type = ParameterTypes(struct.unpack_from("B", data)[0]) - flgas = struct.unpack_from("B", data, 1)[0] - length = struct.unpack_from("H", data, 2)[0] - is_padded = (flgas & padded_bit_field) == padded_bit_field - - if parameter_type == ParameterTypes.Int8Bit: - fragment_data = struct.unpack_from("b", data, 4)[0] - elif parameter_type == ParameterTypes.Int16Bit: - fragment_data = struct.unpack_from("h", data, 4)[0] - elif parameter_type == ParameterTypes.Int32Bit: - fragment_data = struct.unpack_from("i", data, 4)[0] - elif parameter_type == ParameterTypes.UInt8Bit: - fragment_data = struct.unpack_from("B", data, 4)[0] - elif parameter_type == ParameterTypes.UInt16Bit or parameter_type == ParameterTypes.HcResult: - fragment_data = struct.unpack_from("H", data, 4)[0] - elif parameter_type == ParameterTypes.UInt32Bit: - fragment_data = struct.unpack_from("I", data, 4)[0] - elif parameter_type == ParameterTypes.String: - length_adj = length - 1 if is_padded else length - if length_adj > 0: - fragment_data = data[4 : 4 + length_adj - 1].decode("ascii") - else: - fragment_data = "" - elif parameter_type == ParameterTypes.UInt8Array: - length_adj = length - 1 if is_padded else length - fragment_data = list(data[4 : 4 + length_adj]) - elif parameter_type == ParameterTypes.Bool: - fragment_data = struct.unpack_from("?", data, 4)[0] - elif parameter_type == ParameterTypes.Int8Array: - length_adj = length - 1 if is_padded else length - fragment_data = list(struct.unpack_from(f"{length_adj}b", data, 4)) - elif parameter_type == ParameterTypes.Int16Array: - fragment_data = list(struct.unpack_from(f"{length // 2}h", data, 4)) - elif parameter_type == ParameterTypes.UInt16Array: - fragment_data = list(struct.unpack_from(f"{length // 2}H", data, 4)) - elif parameter_type == ParameterTypes.Int32Array: - fragment_data = list(struct.unpack_from(f"{length // 4}i", data, 4)) - elif parameter_type == ParameterTypes.UInt32Array: - fragment_data = list(struct.unpack_from(f"{length // 4}I", data, 4)) - elif parameter_type == ParameterTypes.BoolArray: - # new_types - length_adj = length - 1 if is_padded else length - fragment_data = [struct.unpack_from("?", data, 4 + i)[0] for i in range(length_adj)] - elif parameter_type == ParameterTypes.Real32Bit: - fragment_data = struct.unpack_from("f", data, 4)[0] - elif parameter_type == ParameterTypes.Real64Bit: - fragment_data = struct.unpack_from("d", data, 4)[0] - elif parameter_type == ParameterTypes.Real32Array: - fragment_data = list(struct.unpack_from(f"{length // 4}f", data, 4)) - elif parameter_type == ParameterTypes.Real64Array: - fragment_data = list(struct.unpack_from(f"{length // 8}d", data, 4)) - elif parameter_type == ParameterTypes.Structure: - struct_length = struct.unpack_from("H", data, 2)[0] - struct_data = data[4 : 4 + struct_length] - fragment_data = StructureWrapper(struct_data) - elif parameter_type == ParameterTypes.StructureArray: - struct_length = struct.unpack_from("H", data, 2)[0] - struct_data = data[4 : 4 + struct_length] - structure_wrappers = [] - current_offset = 0 - - while current_offset < len(struct_data): - frag_length = struct.unpack_from("H", struct_data, current_offset + 2)[0] - fragment = parse_data_fragment(struct_data[current_offset:]) - structure_wrappers.append(fragment["fragment_data"]) - current_offset += frag_length + 4 - - fragment_data = structure_wrappers - elif parameter_type == ParameterTypes.Enum: - # decode as 32-bit unsigned integer - fragment_data = struct.unpack_from("I", data, 4)[0] - elif parameter_type == ParameterTypes.EnumArray: - fragment_data = list(struct.unpack_from(f"{length // 4}I", data, 4)) - elif parameter_type == ParameterTypes.Int64Array: - fragment_data = list(struct.unpack_from(f"{length // 8}q", data, 4)) - else: - raise ValueError(f"Unsupported parameter type: {parameter_type}") - - return { - "format": parameter_type, - "flags": flgas, - "length": length + 4, # total length includes the format, flags, and length fields - "is_padded": is_padded, - "fragment_data": fragment_data, - } - - -def encode_data_fragment(obj: Any, parameter_type: ParameterTypes, padded=False) -> bytes: - format = struct.pack("B", parameter_type.value) - data = b"" - flags = 0 - - if parameter_type == ParameterTypes.Int8Bit: - data = struct.pack("b", obj) - elif parameter_type == ParameterTypes.Int16Bit: - data = struct.pack("h", obj) - elif parameter_type == ParameterTypes.Int32Bit: - data = struct.pack("i", obj) - elif parameter_type == ParameterTypes.UInt8Bit: - data = struct.pack("B", obj) - padded = True - elif parameter_type == ParameterTypes.UInt16Bit: - data = struct.pack("H", obj) - elif parameter_type == ParameterTypes.UInt32Bit: - data = struct.pack("I", obj) - elif parameter_type == ParameterTypes.String: - data = obj.encode("ascii") + b"\x00" - elif parameter_type == ParameterTypes.UInt8Array: - data = bytes(obj) - elif parameter_type == ParameterTypes.Bool: - data = struct.pack("?", obj) - padded = True - elif parameter_type == ParameterTypes.Int8Array: - data = struct.pack(f"{len(obj)}b", *obj) - elif parameter_type == ParameterTypes.Int16Array: - data = struct.pack(f"{len(obj)}h", *obj) - elif parameter_type == ParameterTypes.UInt16Array: - data = struct.pack(f"{len(obj)}H", *obj) - elif parameter_type == ParameterTypes.Int32Array: - data = struct.pack(f"{len(obj)}i", *obj) - elif parameter_type == ParameterTypes.UInt32Array: - data = struct.pack(f"{len(obj)}I", *obj) - elif parameter_type == ParameterTypes.BoolArray: - data = b"".join([struct.pack("?", b) for b in obj]) - elif parameter_type == ParameterTypes.Real32Bit: - data = struct.pack("f", obj) - elif parameter_type == ParameterTypes.Real64Bit: - data = struct.pack("d", obj) - elif parameter_type == ParameterTypes.Real32Array: - data = struct.pack(f"{len(obj)}f", *obj) - elif parameter_type == ParameterTypes.Real64Array: - data = struct.pack(f"{len(obj)}d", *obj) - elif parameter_type == ParameterTypes.Structure: - struct_data = obj.encode() - data = struct_data - elif parameter_type == ParameterTypes.StructureArray: - data = b"".join([encode_data_fragment(o, ParameterTypes.Structure) for o in obj]) - elif parameter_type == ParameterTypes.Enum: - # encode as 32-bit unsigned integer - data = struct.pack("I", obj) - elif parameter_type == ParameterTypes.EnumArray: - data = struct.pack(f"{len(obj)}I", *obj) - else: - raise ValueError(f"Unsupported parameter type: {parameter_type}") - - if padded: - flags |= Prep.HoiPacket2.BitField.Padded - data += b"\x00" - - length = len(data) - - return format + struct.pack("B", flags) + struct.pack("H", length) + data - - -class Prep(LiquidHandlerBackend): - def __init__(self, host: str = "192.168.100.102", port: int = 2000): - self.pipettor_source = Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0006)) - self.pipettor_destination = Prep.HarpPacket.HarpAddress((0xE000, 0x0001, 0x1000)) - - self.source_address = Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0004)) - self.destination_address = Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0x1500)) - - self._id = 0 - self.host = host - self.port = port - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - async def setup(self, smart: bool = False): - self.socket.connect((self.host, self.port)) - self.socket.settimeout(30) - - await self.initialize( - tip_drop_params=Prep.InitTipDropParameters( - default_values=True, - x_position=287.0, - rolloff_distance=3, - channel_parameters=[], - ), - smart=smart, - ) - - await super().setup() - - async def stop(self): - self.socket.close() - await super().stop() - - def _generate_id(self) -> int: - """continuously generate unique ids 0 <= x <= 0xff.""" - self._id += 1 - return self._id % 0xFF - - def _assemble_command( - self, - command_id: int, - parameters: List[Tuple[ParameterTypes]], - harp_source: "Prep.HarpPacket.HarpAddress", - harp_destination: "Prep.HarpPacket.HarpAddress", - hoi_action: "Prep.HoiPacket2.Hoi2Action", - ) -> bytes: - hoi_packet = Prep.HoiPacket2( - interface_id=1, - action=hoi_action, - action_id=command_id, - version=0, - data_fragments=[ - encode_data_fragment(value, parameter_type) for value, parameter_type in parameters - ], - ) - - harp_packet = Prep.HarpPacket( - source=harp_source, - destination=harp_destination, - sequence_number=self._generate_id(), - reserved_1=0, - protocol=Prep.HarpPacket.HarpTransportableProtocol.Hoi2, - action=Prep.HarpPacket.Action.create( - Prep.HarpPacket.ResponseRequired.Yes, Prep.HarpPacket.PayloadDescription.CommandRequest - ), - options=[], # TODO: calculate this - version=0, - reserved_2=0, - payload=hoi_packet.encode(), - ) - - ip_packet = Prep.IpPacket( - protocol=Prep.IpPacket.TransportableProtocol.Harp2, - version=(3, 0), - options=None, - payload=harp_packet.encode(), - ) - - return ip_packet.encode() - - def _decode_response(self, response: bytes): - try: - ip_packet = Prep.IpPacket.decode(response) - except ValueError as e: - raise ValueError(f"Failed to decode response: {e}") - - if ip_packet.protocol == Prep.IpPacket.TransportableProtocol.Harp2: - harp_packet = Prep.HarpPacket.decode(ip_packet.payload) - else: - raise ValueError(f"protocol {ip_packet.protocol} not supported") - - if harp_packet.protocol != Prep.HarpPacket.HarpTransportableProtocol.Hoi2: - raise ValueError(f"protocol {harp_packet.protocol} not supported") - - try: - hoi_packet = Prep.HoiPacket2.decode(harp_packet.payload) - except ValueError as e: - raise ValueError(f"Failed to decode HoiPacket2: {e}") - - fragments = hoi_packet.data_fragments - if len(fragments) > 0 and fragments[0]["format"] == ParameterTypes.HcResult: - if fragments[0]["fragment_data"] != 0: - raise ValueError(f"Command failed with error code {fragments[0]['fragment_data']}") - - return - - async def send_command( - self, - command_id: int, - parameters: bytes, - harp_source: "Prep.HarpPacket.HarpAddress", - harp_destination: "Prep.HarpPacket.HarpAddress", - hoi_action: "Prep.HoiPacket2.Hoi2Action" = None, - timeout: Optional[float] = None, - ) -> bytes: - command = self._assemble_command( - command_id=command_id, - parameters=parameters, - harp_source=harp_source, - harp_destination=harp_destination, - hoi_action=hoi_action or Prep.HoiPacket2.Hoi2Action.CommandRequest, - ) - print("Sending command:", command.hex()) - self.socket.send(command) - - response = self.socket.recv(1024) - print("Received response:", response.hex()) - - self._decode_response(response) - - class IpPacket: - FIXED_FORMAT = "H B B H" # ushort, ubyte, ubyte, ushort - FIXED_SIZE = struct.calcsize(FIXED_FORMAT) - - class IpPacketOption: - class Option(IntEnum): - Reserved = 0 - IncompatibleVersion = 1 - UnsupportedOption = 2 - HcResultIpOption = 3 - - BASE_FORMAT = "BB" # Fixed fields: option (1 byte), length (1 byte) - BASE_SIZE = struct.calcsize(BASE_FORMAT) - - def __init__(self, option: Option, length: int, data: bytes = None): - self.option = option - self.length = length - self.data = data or b"" - - def encode(self) -> bytes: - """Encode the IpPacketOption into bytes.""" - if len(self.data) != self.length: - raise ValueError("data length does not match length") - return struct.pack(self.BASE_FORMAT, self.option.value, self.length) + self.data - - @classmethod - def decode(cls, data: bytes) -> "Prep.IpPacketOption": - """Decode the IpPacketOption from bytes.""" - if len(data) < cls.BASE_SIZE: - raise ValueError("Data too small to decode IpPacketOption") - - option, length = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) - data = data[cls.BASE_SIZE : cls.BASE_SIZE + length] if length > 0 else b"" - if len(data) != length: - raise ValueError("data length does not match length in header") - return cls(Prep.IpPacket.IpPacketOption.Option(option), length, data) - - def __repr__(self): - return f"IpPacketOption(option={self.option}, length={self.length}, data={self.data})" - - class TransportableProtocol(IntEnum): - None_ = 0 - Xml = 1 - Bz = 4 - Ml600 = 5 - Harp2 = 6 - Connection = 7 - Serial = 8 - Can = 9 - MultiSerial = 10 - Last = 11 - Invalid = 255 - - def __init__( - self, - protocol: TransportableProtocol, - version: Tuple[int, int], - options: Optional[List[IpPacketOption]], - payload: bytes, - ): - self.protocol = protocol - self.version = version - self.options = options - self.payload = payload - - @property - def size(self) -> int: - # exclude size field (ushort, 2 bytes) - return self.FIXED_SIZE + (self.options_length or 0) + len(self.payload or b"") - 2 - - @property - def options_length(self) -> int: - return sum(option.length for option in self.options) if self.options is not None else 0 - - @classmethod - def decode(cls, data: bytes) -> "Prep.IpPacket": - """Decode an IpPacket from raw bytes.""" - if len(data) < cls.FIXED_SIZE: - raise ValueError( - f"Data is too small to decode (expected at least {cls.FIXED_SIZE} bytes, got {len(data)})" - ) - - # Unpack the fixed fields - size, protocol, version_byte, options_length = struct.unpack( - cls.FIXED_FORMAT, data[: cls.FIXED_SIZE] - ) - version = (version_byte & 240) >> 4, version_byte & 15 - - # Decode options and payload - offset = cls.FIXED_SIZE - options = None - if options_length > 0: - if len(data) < offset + options_length: - raise ValueError("Data too small to contain options") - options = data[offset : offset + options_length] - offset += options_length - - payload = None - if offset < len(data): - payload = data[offset:] - - if not len(data) - 2 == size: - raise ValueError("Packet size does not match size field") - - return cls(Prep.IpPacket.TransportableProtocol(protocol), version, options, payload) - - def encode(self) -> bytes: - """Encode the IpPacket into bytes.""" - version_byte = (self.version[0] << 4) | self.version[1] - header = struct.pack( - self.FIXED_FORMAT, self.size, self.protocol, version_byte, self.options_length - ) - options = self.options or b"" - return header + options + (self.payload or b"") - - class HarpPacket: - class HarpAddress: - FORMAT = "3H" # 3 unsigned shorts - SIZE = struct.calcsize(FORMAT) - - def __init__(self, address: Tuple[int, int, int]): - self.address = address - - def encode(self): - return struct.pack(self.FORMAT, *self.address) - - @classmethod - def decode(cls, data): - return cls(struct.unpack(cls.FORMAT, data[: cls.SIZE])) - - def __str__(self): - return ".".join(hex(byte) for byte in self.address) - - def __eq__(self, other: "Prep.HarpPacket.HarpAddress") -> bool: - return self.address == other.address - - class Action: - FORMAT = "B" # 1 byte - SIZE = struct.calcsize(FORMAT) - - def __init__(self, reserved): - self.reserved = reserved - - @property - def response_required(self): - return (self.reserved & 16) >> 4 - - @property - def payload_description(self): - return self.reserved & 15 - - @staticmethod - def create(response_required, payload_description): - reserved = (response_required << 4) | payload_description - # TODO: why is this named reserved? - return Prep.HarpPacket.Action(reserved) - - def encode(self): - return struct.pack(self.FORMAT, self.reserved) - - @classmethod - def decode(cls, data): - (reserved,) = struct.unpack(cls.FORMAT, data[: cls.SIZE]) - return cls(reserved) - - def __eq__(self, other: "Prep.HarpPacket.Action") -> bool: - return self.reserved == other.reserved - - BASE_FORMAT = ( - HarpAddress.FORMAT * 2 + "BB" + "B" + Action.FORMAT + "H" + "H" + "B" + "B" - ) # source, destination, sequence_number, reserved_1, protocol, action, length, options_length, version, reserved_2 - BASE_SIZE = struct.calcsize(BASE_FORMAT) - - def __init__( - self, - source, - destination, - sequence_number, - reserved_1, - protocol, - action, - options: Optional[List["Prep.HarpPacketOption"]], - version, - reserved_2, - payload: bytes, - ): - self.source = source - self.destination = destination - self.sequence_number = sequence_number - self.reserved_1 = reserved_1 - self.protocol = protocol - self.action = action - self.options = options or [] - self.version = version - self.reserved_2 = reserved_2 - - # not part of the Hamilton implementation, but it is added ad-hoc. We just store it as an - # attribute, similar to IpPacket.payload. - self.payload = payload - - @property - def length(self) -> int: - return self.BASE_SIZE + self.options_length + len(self.payload) - - @property - def options_length(self) -> int: - return sum(option.length for option in self.options) - - def encode(self): - header = struct.pack( - self.BASE_FORMAT, - *self.source.address, - *self.destination.address, - self.sequence_number, - self.reserved_1, - self.protocol.value, - self.action.reserved, - self.length, - self.options_length, - self.version, - self.reserved_2, - ) - options = b"".join(option.encode() for option in self.options) - return header + options + self.payload - - @classmethod - def decode(cls, data): - if len(data) < cls.BASE_SIZE: - raise ValueError(f"Data too small to decode (expected at least {cls.BASE_SIZE} bytes)") - - unpacked_data = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) - source = Prep.HarpPacket.HarpAddress(unpacked_data[:3]) - destination = Prep.HarpPacket.HarpAddress(unpacked_data[3:6]) - sequence_number = unpacked_data[6] - reserved_1 = unpacked_data[7] - protocol = Prep.HarpPacket.HarpTransportableProtocol(unpacked_data[8]) - action = Prep.HarpPacket.Action(unpacked_data[9]) - length = unpacked_data[10] - options_length = unpacked_data[11] - version = unpacked_data[12] - reserved_2 = unpacked_data[13] - - offset = cls.BASE_SIZE - options = [] - for _ in range(options_length): - option = Prep.HarpPacket.HarpPacketOption.decode(data[offset:]) - options.append(option) - offset += Prep.HarpPacket.HarpPacketOption.BASE_SIZE + option.length - - payload = data[offset:] - if not cls.BASE_SIZE + options_length + len(payload) == length: - raise ValueError("Payload length does not match length") - - return cls( - source=source, - destination=destination, - sequence_number=sequence_number, - reserved_1=reserved_1, - protocol=protocol, - action=action, - options=options, - version=version, - reserved_2=reserved_2, - payload=payload, - ) - - class HarpTransportableProtocol(IntEnum): - Hoi2 = 2 - Registration2 = 3 - Lst = 4 - Undefined = 255 - - class PayloadDescription(IntEnum): - StatusRequest = 0 - StatusResponse = 1 - StatusException = 2 - CommandRequest = 3 - CommandResponse = 4 - CommandException = 5 - CommandAck = 6 - UpStreamSystemEvent = 7 - DownStreamSystemEvent = 8 - Event = 9 - InvalidActionResponse = 10 - StatusWarning = 11 - CommandWarning = 12 - - class ResponseRequired(IntEnum): - No = 0 - Yes = 1 - - class HarpPacketOption: - BASE_FORMAT = "BB" # option (1 byte), length (1 byte) - BASE_SIZE = struct.calcsize(BASE_FORMAT) - - class Option(IntEnum): - Reserved = 0 - RoutingError = 1 - IncompatibleVersion = 2 - UnsupportedOptions = 3 - - def __init__(self, option: Option, length: int, data=None): - self.option = option - self.length = length - self.data = data or b"" - - def encode(self): - return struct.pack(self.BASE_FORMAT, self.option, self.length) + self.data - - @classmethod - def decode(cls, data): - base_data = data[: cls.BASE_SIZE] - option, length = struct.unpack(cls.BASE_FORMAT, base_data) - data = data[cls.BASE_SIZE : cls.BASE_SIZE + length] if length > 0 else b"" - return cls(option, length, data) - - class HoiPacket2: - BASE_FORMAT = "B B H BB" # interface_id (1 byte), action (1 byte), action_id (2 bytes), version (1 byte), number_of_fragments (1 byte) - BASE_SIZE = struct.calcsize(BASE_FORMAT) - - def __init__( - self, - interface_id: int, - action: "Prep.HoiPacket2.Hoi2Action", - action_id: int, - version: int, - data_fragments: List[bytes], - ): - self.interface_id = interface_id - self.action = action - self.action_id = action_id - self.version = version - # for Hamilton, this is a list of `DataFragment`s. But, it is easier to just store the encoded bytes. - self.data_fragments = data_fragments - - @property - def number_of_fragments(self) -> int: - return len(self.data_fragments) - - def encode(self): - header = struct.pack( - self.BASE_FORMAT, - self.interface_id, - self.action.value if isinstance(self.action, Prep.HoiPacket2.Hoi2Action) else self.action, - self.action_id, - self.version, - self.number_of_fragments, - ) - return header + b"".join(self.data_fragments) - - @classmethod - def decode(cls, data): - if len(data) < cls.BASE_SIZE: - raise ValueError( - f"Data too small to decode HoiPacket2 (expected at least {cls.BASE_SIZE} bytes)" - ) - - unpacked = struct.unpack(cls.BASE_FORMAT, data[: cls.BASE_SIZE]) - interface_id, action, action_id, version, number_of_fragments = unpacked - - offset = cls.BASE_SIZE - fragments = [] - while offset < len(data): - fragment = parse_data_fragment(data[offset:]) - length = fragment["length"] - fragments.append(fragment) - offset += length - assert len(fragments) == number_of_fragments, "Number of fragments does not match header" - - return cls( - interface_id=interface_id, - action=Prep.HoiPacket2.Hoi2Action(action) - if action <= max(Prep.HoiPacket2.Hoi2Action) - else action, - action_id=action_id, - version=version, - data_fragments=fragments, - ) - - def __repr__(self): - return ( - f"HoiPacket2(interface_id={self.interface_id}, action={self.action}, " - f"action_id={self.action_id}, version={self.version}, " - f"number_of_fragments={self.number_of_fragments}, data_fragments={self.data_fragments})" - ) - - class BitField(IntEnum): - None_ = 0 - Padded = 1 - Unused2 = 2 - Unused3 = 4 - Unused4 = 8 - Unused5 = 65536 - Unused6 = Padded - Unused7 = Unused2 - - class Hoi2Action(IntEnum): - StatusRequest = 0 - StatusResponse = 1 - StatusException = 2 - CommandRequest = 3 - CommandResponse = 4 - CommandException = 5 - CommandAck = 6 - UpStreamSystemEvent = 7 - DownStreamSystemEvent = 8 - Event = 9 - InvalidActionResponse = 10 - StatusWarning = 11 - CommandWarning = 12 - - class Hoi2Eventaction_id(IntEnum): - Registration = 1 - Deregistration = 2 - Event = 3 - - class Hoi2EventAction(IntEnum): - EventRegisterDeregisterRequest = 1 - EventRegisterDeregisterResponse = 2 - EventNotification = 3 - - class DataFragment: - FORMAT = "I" # Example format for individual fragments - SIZE = struct.calcsize(FORMAT) - - def __init__(self, value): - self.value = value - - def encode(self): - return struct.pack(self.FORMAT, self.value) - - @classmethod - def decode(cls, data): - (value,) = struct.unpack(cls.FORMAT, data[: cls.SIZE]) - return cls(value) - - class TadmRecordingModes(IntEnum): - NoRecording = 0 - Errors = 1 - All = 2 - - class PressureMode(IntEnum): - OverPressure = 0 - UnderPressure = 1 - - class LLDStatus(IntEnum): - NotDetected = 0 - Detected = 1 - Disabled = 2 - - class ChannelType(IntEnum): - NoChannel = 0 - UnknownChannelType = 1 - Single1000uLChannel = 2 - MPH8x1000uLChannel = 3 - - class ChannelIndex(IntEnum): - InvalidIndex = 0 - FrontChannel = 1 - RearChannel = 2 - MPHChannel = 3 - - class ChannelAxis(IntEnum): - YAxis = 0 - ZAxis = 1 - SqueezeAxis = 2 - DispenserAxis = 3 - - class MPHChannelID(IntEnum): - MPHChannel1 = 1 - MPHChannel2 = 2 - MPHChannel3 = 3 - MPHChannel4 = 4 - MPHChannel5 = 5 - MPHChannel6 = 6 - MPHChannel7 = 7 - MPHChannel8 = 8 - - class TipDropType(IntEnum): - FixedHeight = 0 - Stall = 1 - CLLDSeek = 2 - - class ZTravelMode(IntEnum): - ZLimitTraverse = 0 - AdjustableTraverse = 1 - CalculatedTraverse = 2 - TerrainFollow = 3 - - class XYTravelMode(IntEnum): - Direct = 0 - XFirst = 1 - YFirst = 2 - Path = 3 - - class VolumeType(IntEnum): - TransportAir = 0 - StopBack = 1 - Liquid = 2 - Blowout = 3 - InitialVolume = 4 - ErrorVolume = 5 - - # class TipTypes(IntEnum): - # UNKNOWN = 0 - # STANDARD = 1 - # FILTER = 2 - # NEEDLE = 3 - - @dataclass - class SeekParameters: - x_start: float # real 32 bit - y_start: float # real 32 bit - z_start: float # real 32 bit - distance: float # real 32 bit - expected_position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.x_start, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_start, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_start, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.distance, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.expected_position, ParameterTypes.Real32Bit) - return out - - @dataclass - class XYZCoord: - default_values: bool # bool - x_position: float # real 32 bit - y_position: float # real 32 bit - z_position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) - return out - - @dataclass - class XYCoord: - default_values: bool # bool - x_position: float # real 32 bit - y_position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - return out - - @dataclass - class ChannelYZMoveParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - y_position: float # real 32 bit - z_position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) - return out - - @dataclass - class GantryMoveXYZParameters: - default_values: bool # bool - gantry_x_position: float # real 32 bit - axis_parameters: list["Prep.ChannelYZMoveParameters"] # array of ChannelYZMoveParameters - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.gantry_x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.axis_parameters, ParameterTypes.StructureArray) - return out - - @dataclass - class PlateDimensions: - default_values: bool # bool - length: float # real 32 bit - width: float # real 32 bit - height: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.width, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.height, ParameterTypes.Real32Bit) - return out - - @dataclass - class TipDefinition: - default_values: bool # bool - id: int # byte (UInt8Bit) - volume: float # real 32 bit - length: float # real 32 bit - tip_type: "Prep.TipTypes" # enum - has_filter: bool # bool - is_needle: bool # bool - is_tool: bool # bool - label: str # string - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.id, ParameterTypes.UInt8Bit) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) - out += encode_data_fragment(self.has_filter, ParameterTypes.Bool) - out += encode_data_fragment(self.is_needle, ParameterTypes.Bool) - out += encode_data_fragment(self.is_tool, ParameterTypes.Bool) - out += encode_data_fragment(self.label, ParameterTypes.String) - return out - - @dataclass - class TipPickupParameters: - default_values: bool # bool - volume: float # real 32 bit - length: float # real 32 bit - tip_type: "Prep.TipTypes" # enum - has_filter: bool # bool - is_needle: bool # bool - is_tool: bool # bool - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.length, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) - out += encode_data_fragment(self.has_filter, ParameterTypes.Bool) - out += encode_data_fragment(self.is_needle, ParameterTypes.Bool) - out += encode_data_fragment(self.is_tool, ParameterTypes.Bool) - return out - - @dataclass - class AspirateParameters: - default_values: bool # bool - x_position: float # real 32 bit - y_position: float # real 32 bit - prewet_volume: float # real 32 bit - blowout_volume: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.prewet_volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.blowout_volume, ParameterTypes.Real32Bit) - return out - - @dataclass - class DispenseParameters: - default_values: bool # bool - x_position: float # real 32 bit - y_position: float # real 32 bit - stop_back_volume: float # real 32 bit - cutoff_speed: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.stop_back_volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.cutoff_speed, ParameterTypes.Real32Bit) - return out - - @dataclass - class CommonParameters: - default_values: bool # bool - empty: bool # bool - z_minimum: float # real 32 bit - z_final: float # real 32 bit - z_liquid_exit_speed: float # real 32 bit - liquid_volume: float # real 32 bit - liquid_speed: float # real 32 bit - transport_air_volume: float # real 32 bit - tube_radius: float # real 32 bit - cone_height: float # real 32 bit - cone_bottom_radius: float # real 32 bit - settling_time: float # real 32 bit - additional_probes: int # uint 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.empty, ParameterTypes.Bool) - out += encode_data_fragment(self.z_minimum, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_liquid_exit_speed, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.liquid_volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.liquid_speed, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.transport_air_volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.tube_radius, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.cone_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.cone_bottom_radius, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.settling_time, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.additional_probes, ParameterTypes.UInt32Bit) - return out - - @dataclass - class NoLldParameters: - default_values: bool # bool - z_fluid: float # real 32 bit - z_air: float # real 32 bit - bottom_search: bool # bool - z_bottom_search_offset: float # real 32 bit - z_bottom_offset: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.z_fluid, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_air, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.bottom_search, ParameterTypes.Bool) - out += encode_data_fragment(self.z_bottom_search_offset, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_bottom_offset, ParameterTypes.Real32Bit) - return out - - @dataclass - class LldParameters: - default_values: bool # bool - z_seek: float # real 32 bit - z_seek_speed: float # real 32 bit - z_submerge: float # real 32 bit - z_out_of_liquid: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_seek_speed, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_submerge, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_out_of_liquid, ParameterTypes.Real32Bit) - return out - - @dataclass - class CLldParameters: - default_values: bool # bool - sensitivity: "Prep.LldSensitivities" # enum - clot_check_enable: bool # bool - z_clot_check: float # real 32 bit - detect_mode: "Prep.DetectModes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.sensitivity, ParameterTypes.Enum) - out += encode_data_fragment(self.clot_check_enable, ParameterTypes.Bool) - out += encode_data_fragment(self.z_clot_check, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) - return out - - @dataclass - class PLldParameters: - default_values: bool # bool - sensitivity: "Prep.LldSensitivities" # enum - dispenser_seek_speed: float # real 32 bit - lld_height_difference: float # real 32 bit - detect_mode: "Prep.DetectModes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.sensitivity, ParameterTypes.Enum) - out += encode_data_fragment(self.dispenser_seek_speed, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.lld_height_difference, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) - return out - - @dataclass - class TadmReturnParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - entries: int # uint 32 bit - error: bool # bool - data: list[int] # array of short (16-bit signed) - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.entries, ParameterTypes.UInt32Bit) - out += encode_data_fragment(self.error, ParameterTypes.Bool) - out += encode_data_fragment(self.data, ParameterTypes.Int16Array) - return out - - @dataclass - class TadmParameters: - default_values: bool # bool - limit_curve_index: int # ushort - recording_mode: "Prep.TadmRecordingModes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.limit_curve_index, ParameterTypes.UInt16Bit) - out += encode_data_fragment(self.recording_mode, ParameterTypes.Enum) - return out - - @classmethod - def default(cls) -> "Prep.TadmParameters": - return cls( - default_values=True, - limit_curve_index=0, - recording_mode=Prep.TadmRecordingModes.Errors, - ) - - @dataclass - class AspirateMonitoringParameters: - default_values: bool # bool - c_lld_enable: bool # bool - p_lld_enable: bool # bool - minimum_differential: int # ushort - maximum_differential: int # ushort - clot_threshold: int # ushort - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.c_lld_enable, ParameterTypes.Bool) - out += encode_data_fragment(self.p_lld_enable, ParameterTypes.Bool) - out += encode_data_fragment(self.minimum_differential, ParameterTypes.UInt16Bit) - out += encode_data_fragment(self.maximum_differential, ParameterTypes.UInt16Bit) - out += encode_data_fragment(self.clot_threshold, ParameterTypes.UInt16Bit) - return out - - @classmethod - def default(cls) -> "Prep.AspirateMonitoringParameters": - return cls( - default_values=True, - c_lld_enable=False, - p_lld_enable=False, - minimum_differential=30, - maximum_differential=30, - clot_threshold=20, - ) - - @dataclass - class MixParameters: - default_values: bool # bool - z_offset: float # real 32 bit - volume: float # real 32 bit - cycles: int # byte (UInt8Bit) - speed: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.z_offset, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.cycles, ParameterTypes.UInt8Bit, padded=True) - out += encode_data_fragment(self.speed, ParameterTypes.Real32Bit) - return out - - @classmethod - def default(cls) -> "Prep.MixParameters": - return cls( - default_values=True, - z_offset=0.0, - volume=0.0, - cycles=0, - speed=250.0, - ) - - @dataclass - class AdcParameters: - default_values: bool # bool - errors: bool # bool - maximum_volume: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.errors, ParameterTypes.Bool) - out += encode_data_fragment(self.maximum_volume, ParameterTypes.Real32Bit) - return out - - @classmethod - def default(cls) -> "Prep.AdcParameters": - return cls( - default_values=True, - errors=True, - maximum_volume=4.5, - ) - - @dataclass - class ChannelXYZPositionParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - position_x: float # real 32 bit - position_y: float # real 32 bit - position_z: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.position_x, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.position_z, ParameterTypes.Real32Bit) - return out - - @dataclass - class PressureReturnParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - pressure: int # ushort - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.pressure, ParameterTypes.UInt16Bit) - return out - - @dataclass - class LiquidHeightReturnParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - c_lld_detected: bool # bool - c_lld_liquid_height: float # real 32 bit - p_lld_detected: bool # bool - p_lld_liquid_height: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.c_lld_detected, ParameterTypes.Bool) - out += encode_data_fragment(self.c_lld_liquid_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.p_lld_detected, ParameterTypes.Bool) - out += encode_data_fragment(self.p_lld_liquid_height, ParameterTypes.Real32Bit) - return out - - @dataclass - class DispenserVolumeReturnParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - volume: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - return out - - @dataclass - class PotentiometerParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - gain: int # byte (UInt8Bit) - offset: int # byte (UInt8Bit) - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.gain, ParameterTypes.UInt8Bit) - out += encode_data_fragment(self.offset, ParameterTypes.UInt8Bit) - return out - - @dataclass - class YLLDSeekParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - start_position_x: float # real 32 bit - start_position_y: float # real 32 bit - start_position_z: float # real 32 bit - seek_position_y: float # real 32 bit - seek_velocity_y: float # real 32 bit - lld_sensitivity: "Prep.LldSensitivities" # enum - detect_mode: "Prep.DetectModes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.start_position_x, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.start_position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.start_position_z, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_velocity_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.lld_sensitivity, ParameterTypes.Enum) - out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) - return out - - @dataclass - class ChannelSeekParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - seek_position_x: float # real 32 bit - seek_position_y: float # real 32 bit - seek_height: float # real 32 bit - min_seek_height: float # real 32 bit - final_position_z: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.seek_position_x, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.min_seek_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.final_position_z, ParameterTypes.Real32Bit) - return out - - @dataclass - class LLDChannelSeekParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - seek_position_x: float # real 32 bit - seek_position_y: float # real 32 bit - seek_velocity_z: float # real 32 bit - seek_height: float # real 32 bit - min_seek_height: float # real 32 bit - final_position_z: float # real 32 bit - lld_sensitivity: "Prep.LldSensitivities" # enum - detect_mode: "Prep.DetectModes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.seek_position_x, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_velocity_z, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.min_seek_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.final_position_z, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.lld_sensitivity, ParameterTypes.Enum) - out += encode_data_fragment(self.detect_mode, ParameterTypes.Enum) - return out - - @dataclass - class SeekResultParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - detected: bool # bool - position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.detected, ParameterTypes.Bool) - out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) - return out - - @dataclass - class ChannelCounterParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - tip_pickup_counter: int # uint 32 bit - tip_eject_counter: int # uint 32 bit - aspirate_counter: int # uint 32 bit - dispense_counter: int # uint 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.tip_pickup_counter, ParameterTypes.UInt32Bit) - out += encode_data_fragment(self.tip_eject_counter, ParameterTypes.UInt32Bit) - out += encode_data_fragment(self.aspirate_counter, ParameterTypes.UInt32Bit) - out += encode_data_fragment(self.dispense_counter, ParameterTypes.UInt32Bit) - return out - - @dataclass - class ChannelCalibrationParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - dispenser_return_steps: int # uint 32 bit - squeeze_position: float # real 32 bit - z_touchoff: float # real 32 bit - z_tip_height: float # real 32 bit - pressure_monitoring_shift: int # uint 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.dispenser_return_steps, ParameterTypes.UInt32Bit) - out += encode_data_fragment(self.squeeze_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_touchoff, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_tip_height, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.pressure_monitoring_shift, ParameterTypes.UInt32Bit) - return out - - @dataclass - class LeakCheckSimpleParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - time: float # real 32 bit - high_pressure: bool # bool - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.time, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.high_pressure, ParameterTypes.Bool) - return out - - @dataclass - class LeakCheckParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - start_position_x: float # real 32 bit - start_position_y: float # real 32 bit - start_position_z: float # real 32 bit - seek_distance_y: float # real 32 bit - pre_load_distance_y: float # real 32 bit - final_z: float # real 32 bit - tip_definition_id: int # byte (UInt8Bit) - test_time: float # real 32 bit - high_pressure: bool # bool - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.start_position_x, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.start_position_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.start_position_z, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.seek_distance_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.pre_load_distance_y, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.final_z, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.tip_definition_id, ParameterTypes.UInt8Bit) - out += encode_data_fragment(self.test_time, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.high_pressure, ParameterTypes.Bool) - return out - - @dataclass - class ChannelDriveStatus: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - y_axis_drive_status: "Prep.DriveStatus" # struct - z_axis_drive_status: "Prep.DriveStatus" # struct - dispenser_drive_status: "Prep.DriveStatus" # struct - squeeze_drive_status: "Prep.DriveStatus" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.y_axis_drive_status, ParameterTypes.Structure) - out += encode_data_fragment(self.z_axis_drive_status, ParameterTypes.Structure) - out += encode_data_fragment(self.dispenser_drive_status, ParameterTypes.Structure) - out += encode_data_fragment(self.squeeze_drive_status, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersNoLldAndMonitoring: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersNoLldAndTadm: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersLldAndMonitoring: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - p_lld: "Prep.PLldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct - adc: "Prep.AdcParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersLldAndTadm: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - p_lld: "Prep.PLldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - tadm: "Prep.TadmParameters" # struct - adc: "Prep.AdcParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - return out - - @dataclass - class DispenseParametersNoLld: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - dispense: "Prep.DispenseParameters" # struct - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.dispense, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - @dataclass - class DispenseParametersLld: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - dispense: "Prep.DispenseParameters" # struct - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.dispense, ParameterTypes.Structure) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - @dataclass - class DropTipParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - y_position: float # real 32 bit - z_seek: float # real 32 bit - z_tip: float # real 32 bit - z_final: float # real 32 bit - z_seek_speed: float # real 32 bit - drop_type: "Prep.TipDropType" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_tip, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_seek_speed, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.drop_type, ParameterTypes.Enum) - return out - - @dataclass - class InitTipDropParameters: - default_values: bool # bool - x_position: float # real 32 bit - rolloff_distance: float # real 32 bit - channel_parameters: list["Prep.DropTipParameters"] # array of DropTipParameters - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.rolloff_distance, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.channel_parameters, ParameterTypes.StructureArray) - return out - - @dataclass - class DispenseInitToWasteParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - x_position: float # real 32 bit - y_position: float # real 32 bit - z_position: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) - return out - - @dataclass - class MoveAxisAbsoluteParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - axis: "Prep.ChannelAxis" # enum - position: float # real 32 bit - delay: int # uint 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.axis, ParameterTypes.Enum) - out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.delay, ParameterTypes.UInt32Bit) - return out - - @dataclass - class MoveAxisRelativeParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - axis: "Prep.ChannelAxis" # enum - distance: float # real 32 bit - delay: int # uint 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.axis, ParameterTypes.Enum) - out += encode_data_fragment(self.distance, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.delay, ParameterTypes.UInt32Bit) - return out - - @dataclass - class LimitCurveEntry: - default_values: bool # bool - sample: int # ushort (UInt16Bit) - pressure: int # short (Int16) - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.sample, ParameterTypes.UInt16Bit) - out += encode_data_fragment(self.pressure, ParameterTypes.Int16Bit) - return out - - @dataclass - class TipPositionParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - x_position: float # real 32 bit - y_position: float # real 32 bit - z_position: float # real 32 bit - z_seek: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) - return out - - @dataclass - class TipDropParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - x_position: float # real 32 bit - y_position: float # real 32 bit - z_position: float # real 32 bit - z_seek: float # real 32 bit - drop_type: "Prep.TipDropType" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_seek, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.drop_type, ParameterTypes.Enum) - return out - - @dataclass - class TipHeightCalibrationParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - x_position: float # real 32 bit - y_position: float # real 32 bit - z_start: float # real 32 bit - z_stop: float # real 32 bit - z_final: float # real 32 bit - volume: float # real 32 bit - tip_type: "Prep.TipTypes" # enum - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.x_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.y_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_start, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_stop, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.z_final, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.tip_type, ParameterTypes.Enum) - return out - - @dataclass - class DispenserVolumeEntry: - default_values: bool # bool - type: "Prep.VolumeType" # enum - volume: float # real 32 bit - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.type, ParameterTypes.Enum) - out += encode_data_fragment(self.volume, ParameterTypes.Real32Bit) - return out - - @dataclass - class DispenserVolumeStackReturnParameters: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - total_volume: float # real 32 bit - volumes: list["Prep.DispenserVolumeEntry"] # array of DispenserVolumeEntry - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.total_volume, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.volumes, ParameterTypes.StructureArray) - return out - - @dataclass - class AspirateParametersNoLldAndMonitoring2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersNoLldAndTadm2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersLldAndMonitoring2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - p_lld: "Prep.PLldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - aspirate_monitoring: "Prep.AspirateMonitoringParameters" # struct - adc: "Prep.AdcParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.aspirate_monitoring, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - return out - - @dataclass - class AspirateParametersLldAndTadm2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - aspirate: "Prep.AspirateParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - p_lld: "Prep.PLldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - tadm: "Prep.TadmParameters" # struct - adc: "Prep.AdcParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.aspirate, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.p_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - return out - - @dataclass - class DispenseParametersNoLld2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - dispense: "Prep.DispenseParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - no_lld: "Prep.NoLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.dispense, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.no_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - @dataclass - class DispenseParametersLld2: - default_values: bool # bool - channel: "Prep.ChannelIndex" # enum - dispense: "Prep.DispenseParameters" # struct - container_description: list["Prep.SegmentDescriptor"] # array of SegmentDescriptor - common: "Prep.CommonParameters" # struct - lld: "Prep.LldParameters" # struct - c_lld: "Prep.CLldParameters" # struct - mix: "Prep.MixParameters" # struct - adc: "Prep.AdcParameters" # struct - tadm: "Prep.TadmParameters" # struct - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.default_values, ParameterTypes.Bool) - out += encode_data_fragment(self.channel, ParameterTypes.Enum) - out += encode_data_fragment(self.dispense, ParameterTypes.Structure) - out += encode_data_fragment(self.container_description, ParameterTypes.StructureArray) - out += encode_data_fragment(self.common, ParameterTypes.Structure) - out += encode_data_fragment(self.lld, ParameterTypes.Structure) - out += encode_data_fragment(self.c_lld, ParameterTypes.Structure) - out += encode_data_fragment(self.mix, ParameterTypes.Structure) - out += encode_data_fragment(self.adc, ParameterTypes.Structure) - out += encode_data_fragment(self.tadm, ParameterTypes.Structure) - return out - - class Error(IntEnum): - ChannelsBusy = 3585 - InvalidChannelIndex = 3586 - SiteNotDefined = 3587 - ChannelPowerRemoved = 3588 - HeadlessChannel = 3589 - CoordinatorProxyTimeout = 3590 - CalibrationInProgress = 3591 - - # User-discovered - # TipNotFound = 3848 - - class HcResult(IntEnum): - Success = 0x0000 - GenericError = 0x0001 - GenericNotReady = 0x0002 - GenericNullParameter = 0x0003 - GenericCalledByInitHandler = 0x0004 - GenericInvalidData = 0x0005 - GenericOutOfMemory = 0x0006 - GenericWriteFault = 0x0007 - GenericReadFault = 0x0008 - GenericBufferOverflow = 0x0009 - GenericNotInitialized = 0x000A - GenericAlreadyInitialized = 0x000B - GenericWaitAborted = 0x000C - GenericTimeOut = 0x000D - GenericMissingCallBack = 0x000E - GenericInvalidHandle = 0x000F - GenericNotSupported = 0x0010 - GenericInvalidParameter = 0x0011 - GenericNotImplemented = 0x0012 - GenericBadCrc = 0x0013 - GenericFlashNotBlank = 0x0014 - GenericMultipleErrorsReported = 0x0015 - GenericCoordinatedCommandTimeout = 0x0016 - GenericAccessDenied = 0x0017 - GenericBusy = 0x0019 - GenericMethodObsolete = 0x001A - GenericNotConfigured = 0x001B - GenericNotCalibrated = 0x001C - GenericOptionalFunctionalityNotPresent = 0x001D - GenericResumeFromInvalidState = 0x001E - GenericAbortFromInvalidState = 0x001F - GenericActionAborted = 0x0020 - GenericPauseFromInvalidState = 0x0021 - GenericPaused = 0x0022 - GenericSuspended = 0x0023 - GenericExitSuspendFromInvalidState = 0x0024 - KernelMutexTimeout = 0x0101 - KernelSemaphoreTimeout = 0x0102 - KernelEventTimeout = 0x0103 - KernelNoMutex = 0x0104 - KernelMutexNotOwned = 0x0105 - KernelNoWaitingTask = 0x0106 - KernelInvalidTask = 0x0107 - KernelNoTaskControlBlock = 0x0108 - NetworkUndefinedProtocol = 0x0201 - NetworkNoDestination = 0x0202 - NetworkRegistrationError = 0x0203 - NetworkNotRegistered = 0x0204 - NetworkBusy = 0x0205 - NetworkInvalidDispatchID = 0x0206 - NetworkInvalidMessage = 0x0207 - NetworkUnsupportedParameter = 0x0208 - NetworkCommandCompleteNotValid = 0x0209 - NetworkInvalidMessageParameter = ( - 0x020A # went command id is wrong, or when parameters don't match the command - ) - NetworkIncompatibleProtocolVersion = 0x020B - NetworkInvalidNodeId = 0x020C - NetworkInvalidModuleId = 0x020D - NetworkInvalidInterfaceId = 0x020E - NetworkInvalidAction = 0x020F - NetworkProxySendAttemptFailed = 0x0210 - NetworkRegistrationFailedDuplicateAddress = 0x0211 - NetworkUnableToProperlyFillOutResults = 0x0212 - NetworkDuplicateEventRegistration = 0x0213 - NetworkEventRegistrationExceedsMaximumAllowedSubscribers = 0x0214 - NetworkMaximumNodeToNodeEventRegistrationsExceeded = 0x0215 - NetworkMaximumNodeToNodeEventHandlerRegistrationsExceeded = 0x0216 - NetworkUnsupportedHarpPayloadProtocol = 0x0217 - NetworkUnableToSubscribeInvalidEvent = 0x0218 - NetworkGlobalObjectDefinedButNotInstantiated = 0x0219 - NetworkNodeGlobalObjectDefinedButNotInstantiated = 0x021A - NetworkProxyRequestValidationFailed = 0x021B - XPortSlOsPortNotInstalled = 0x0301 - XPortSlIpTaskPriorityNotSet = 0x0302 - XPortSlTimerTaskPriorityNotSet = 0x0303 - XPortSlDriverNotSet = 0x0304 - XPortSlIpAddressNotSet = 0x0305 - XPortSlNetMaskNotSet = 0x0306 - XPortSlCmxInitFailure = 0x0307 - XPortSlMacAddressNotSet = 0x0308 - XPortSlHostNameTooShort = 0x0309 - XPortSlNostNameTooLong = 0x030A - XPortSlHostNameInvalidChars = 0x030B - XPortNxpLpc2xxxCanInvalidChannel = 0x0320 - XPortNxpLpc2xxxCanInvalidGroup = 0x0321 - XPortNxpLpc2xxxCanBitRate = 0x0322 - XPortNxpLpc2xxxCanRxInterruptInstall = 0x0323 - XPortNxpLpc2xxxCanRxInterrupRemove = 0x0324 - XPortNxpLpc2xxxCanTxInterruptInstall = 0x0325 - XPortNxpLpc2xxxCanTxInterrupRemove = 0x0326 - XPortNxpLpc2xxxCanTxInvalidLength = 0x0327 - XPortNxpLpc2xxxCanTxBusy = 0x0328 - XPortArcNetAlreadyConfigured = 0x0329 - XPortArcNetNotConfigured = 0x032A - XPortArcNetInterruptInstallFailed = 0x032B - XPortArcNetTxNoAck = 0x032C - XPortArcNetDiagnosticTestFailed = 0x032D - XPortArcNetNodeIdTestFailed = 0x032E - XPortArcNetInvalidNodeId = 0x032F - XPortArcNetTxNotAvailable = 0x0330 - XPortArcNetInvalidDataRate = 0x0331 - XPortArcNetInvalidPacketLength = 0x0332 - XPortArcNetSingleNodeNetwork = 0x0333 - XPortArcNetNoResponseToFbe = 0x0334 - XPortProtocolMismatch = 0x0341 - XPortPacketRouterNotRegistered = 0x0342 - XPortCouldNotStartPacketRouterRxThread = 0x0343 - XPortPacketRouterAlreadyRegistered = 0x0344 - XPortNoPacketToProcess = 0x0345 - XPortWireProtocolNotRegistered = 0x0346 - XPortWireProtocolAlreadyRegistered = 0x0347 - XPortWireProtocolRegistrationSpaceFull = 0x0348 - XPortPayloadProtocolNotRegistered = 0x0349 - XPortPayloadProtocolAlreadyRegsitered = 0x034A - XPortPayloadRegistrationSpaceFull = 0x034B - XPortAddressNotSet = 0x034C - XPortAttemptToSendToSelf = 0x034D - XPortTxTimeout = 0x034E - XPortRxDuplicateFrame = 0x034F - XPortCanWp0VersionConflict = 0x0360 - XPortCanExcessivePacketSize = 0x0361 - XPortCanWp0AckHasNoMatchingPacket = 0x0362 - XPortCanWp0WrapperOnlyOneAddressSupported = 0x0363 - XPortCanWp0ErrorStartRefused = 0x0364 - XPortCanWp0ErrorBufferOverrun = 0x0365 - XPortCanWp0InvalidFrame = 0x0366 - XPortCanWp0StrayDataFrame = 0x0367 - XPortCanWp0ShortMessage = 0x0368 - XPortCanWp0LongMessage = 0x0369 - XPortCanWp0UnknownError = 0x036A - XPortCanWp0NoResponseFromDestination = 0x036B - XPortCanWp0SendError = 0x036C - XPortCanWbzUnknownFrame = 0x036D - XPortCanWbzUnsolicitedRemoteFrame = 0x036E - XPortCanWbzUnsolicitedDataFrame = 0x036F - XPortCanWbzWrapperOnlyOneAddressSupported = 0x0370 - XPortCanWp0LastMessageFailed = 0x0371 - XPortIpStackConfigurationFailure = 0x0380 - XPortIpStackNotConfigured = 0x0381 - XPortSocketCreationFailure = 0x0382 - XPortSocketConfigFailure = 0x0383 - XPortSocketBindFailure = 0x0384 - XPortIpTaskAlreadyStarted = 0x0385 - XPortIpTaskNotStarted = 0x0386 - XPortTcpListenFailure = 0x0387 - XPortTcpClientAlreadyConnected = 0x0388 - XPortTcpClientNotConnected = 0x0389 - XPortTcpConnectionFailure = 0x038A - XPortTcpCloseFailure = 0x038B - XPortTcpSendError = 0x038C - XPortUdpSendError = 0x038D - XPortMalformedDiscoveryRequest = 0x038E - XPortIpDhcpFailed = 0x038F - XPortIpStaticAddressConfigFailed = 0x0390 - XPortArcNetBufferOverrun = 0x03A0 - XPortArcNetVersionConflict = 0x03A1 - XPortArcNetInvalidFrameType = 0x03A2 - XPortArcNetInvalidFrame = 0x03A3 - XPortArcNetUnknownError = 0x03A4 - XPortArcNetAckHasNoMatchingPacket = 0x03A5 - XPortArcNetInvalidMessageSize = 0x03A6 - XPortArcNetLastMessageFailed = 0x03A7 - XPortArcNetWp0RefusedSyn = 0x03A8 - XPortArcNetWp0MessageTooShort = 0x03A9 - XPortArcNetWp0MessageTooLong = 0x03AA - XPortArcNetWp0InvalidSequenceNumber = 0x03AB - XPortArcNetWp0NoResponseFromDestination = 0x03AC - XPortRS232PppTimeout = 0x03C0 - ComLinkReferToInnerException = 0x0400 - ComLinkNotConnected = 0x0401 - ComLinkTcpConnectionFailed = 0x0402 - ComLinkFailedToCloseConnectionProperly = 0x0403 - ComLinkInvalidProtocolVersion = 0x0404 - ComLinkUnsupportedOptionsDetectedByServer = 0x0405 - ComLinkNodeIdNegotiationFailure = 0x0406 - ComLinkConnectionIntentError = 0x0407 - ComLinkUnableToConfigureKeepAlive = 0x0408 - ComLinkFailedToSendConnectionPacket = 0x0409 - ComLinkInvalidRegistrationAction = 0x040A - ComLinkUnexpectedRequestedHarpAddressReturned = 0x040B - ComLinkHarpAddressRegistrationFailed = 0x040C - ComLinkHarpAddressDeregistrationFailed = 0x040D - ComLinkIdentificationNotImplemented = 0x040E - ComLinkIdentificationNotSupported = 0x040F - ComLinkFailedToSendIdentificationRequest = 0x0410 - ComLinkNoResponseFromInstrumentRegistrationServer = 0x0411 - ComLinkNoRootObjectFound = 0x0412 - ComLinkEthernetObjectNotFound = 0x0413 - ComLinkMethodNotFound = 0x0414 - ComLinkProtocolActionConversionFailed = 0x0415 - ComLinkTimeout = 0x0416 - ComLinkUnableToSendOrReceive = 0x0417 - ComLinkTransportTransportableIntroductionFailure = 0x0418 - ComLinkHarpHarpableIntroductionFailure = 0x0419 - ComLinkDownloadException = 0x041A - ComLinkSizeOfReturnParametersNotValid = 0x041B - ComLinkRestrictedMethod = 0x041C - ComLinkInvalidNumberOfStructureParametersFromNetworkLayer = 0x041D - ComLinkInvalidTypeInStructureFromNetworkLayer = 0x041E - ComLinkRs232ConnectionFailed = 0x041F - ComLinkRs232InvalidPort = 0x0420 - ComLinkLoggingCannotBeConfiguredWhileConnectedOrConnecting = 0x0421 - ComLinkThreadAbortExceptionDetected = 0x0422 - ComLinkUnableToSend = 0x0423 - ComLinkUnableToReceive = 0x0424 - ComLinkConnectionRequiredToProceed = 0x0425 - ComLinkTooMuchDataToSend = 0x0426 - ComLinkCanConfigurationFailure = 0x0427 - ComLinkUnableToRetrieveListOfModules = 0x0428 - ComLinkTcpConnectionFailedConnectionRefused = 0x0429 - ComLinkTcpConnectionFailedHostUnreachable = 0x042A - ComLinkTcpConnectionFailedHostNotFound = 0x042B - ComLinkTcpConnectionFailedTimedOut = 0x042C - ComLinkTcpConnectionFailedIsConnected = 0x042D - ComLinkConnectionClosedWithOutstandingRequest = 0x042E - ComLinkNotConfigured = 0x042F - ComLinkRs232MultiFailedToConnect = 0x0430 - ComLinkAttemptToCallNonStatusRequestMethodWithMonitorConnection = 0x0431 - ComLinkPauseResumeFunctionalityNotSupported = 0x0432 - ComLinkFailedToCreateDeviceHandleForUsbDevice = 0x0433 - ComLinkUsbDeviceNotAvailable = 0x0434 - ComLinkUsbConnectionFailed = 0x0435 - ComLinkUsbConnectionLost = 0x0436 - ComLinkBonaduzError = 0x0437 - ComLinkUsbMultiFailedToConnect = 0x0438 - GenericMultipleWarningsReported = 0x8018 - - class TipTypes(IntEnum): - None_ = 0 # Use None_ since None is a reserved keyword in Python - LowVolume = 1 - StandardVolume = 2 - HighVolume = 3 - - class LldSensitivities(IntEnum): - Low = 0 - MediumLow = 1 - MediumHigh = 2 - High = 3 - Tool = 4 - Waste = 5 - - class DetectModes(IntEnum): - Any = 0 - Primary = 1 - Secondary = 2 - All = 3 - - class YAcceleration(IntEnum): - YLowestAcceleration = 1 - YLowAcceleration = 2 - YMediumAcceleration = 3 - YDefaultAcceleration = 4 - - @dataclass - class DriveStatus: - initialized: bool - position: float - encoder_position: float - in_home_sensor: bool - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.initialized, ParameterTypes.Bool) - out += encode_data_fragment(self.position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.encoder_position, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.in_home_sensor, ParameterTypes.Bool) - return out - - @dataclass - class SegmentDescriptor: - area_top: float - area_bottom: float - height: float - - def encode(self) -> bytes: - out = b"" - out += encode_data_fragment(self.area_top, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.area_bottom, ParameterTypes.Real32Bit) - out += encode_data_fragment(self.height, ParameterTypes.Real32Bit) - return out - - # Liquid handler backend commands - - @property - def num_channels(self) -> int: - return 2 - - async def pick_up_tips( - self, - ops: List[Pickup], - use_channels: List[int], - final_z: float = 123.87, - timeout: Optional[float] = None, - ): - tip_parameters = [] - - assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported for now" - - indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} - for channel_idx in range(2): - if channel_idx in indexed_ops: - op = indexed_ops[channel_idx] - loc = op.resource.get_absolute_location("c", "c", "t") - z = loc.z + op.resource.get_tip().total_tip_length - - tip_parameters.append( - Prep.TipPositionParameters( - default_values=False, - channel={ - 0: Prep.ChannelIndex.RearChannel, - 1: Prep.ChannelIndex.FrontChannel, - }[channel_idx], - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z + 12, # ? - ) - ) - - seek_speed = 15.0 - - assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip" - tip = ops[0].tip - tip_definition = Prep.TipPickupParameters( - default_values=False, - volume=tip.maximal_volume, - length=tip.total_tip_length - tip.fitting_depth, - tip_type=Prep.TipTypes.StandardVolume, # ? - has_filter=tip.has_filter, - is_needle=False, - is_tool=False, - ) - enable_tadm = False - dispenser_volume = 0.0 - dispenser_speed = 250.0 - - return await self.send_command( - command_id=9, - parameters=[ - (tip_parameters, ParameterTypes.StructureArray), - (final_z, ParameterTypes.Real32Bit), - (seek_speed, ParameterTypes.Real32Bit), - (tip_definition, ParameterTypes.Structure), - (enable_tadm, ParameterTypes.Bool), - (dispenser_volume, ParameterTypes.Real32Bit), - (dispenser_speed, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def drop_tips( - self, - ops: List[Drop], - use_channels: List[int], - final_z: float = 123.87, - seek_speed: float = 10.0, - tip_roll_off_distance: float = 0.0, - timeout: Optional[float] = None, - ): - """Drop tips from the specified resource.""" - - tip_parameters = [] - - assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported for now" - - indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} - - for channel_idx in range(2): - if channel_idx in indexed_ops: - op = indexed_ops[channel_idx] - loc = op.resource.get_absolute_location("c", "c", "t") - z = loc.z + op.resource.get_tip().total_tip_length - - tip_parameters.append( - Prep.TipDropParameters( - default_values=False, - channel={ - 0: Prep.ChannelIndex.RearChannel, - 1: Prep.ChannelIndex.FrontChannel, - }[channel_idx], - x_position=loc.x, - y_position=loc.y, - z_position=z, - z_seek=z + 12, - drop_type=Prep.TipDropType.FixedHeight, - ) - ) - - return await self.send_command( - command_id=12, - parameters=[ - (tip_parameters, ParameterTypes.StructureArray), - (final_z, ParameterTypes.Real32Bit), - (seek_speed, ParameterTypes.Real32Bit), - (tip_roll_off_distance, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - z_final: float = 96.97, - timeout: Optional[float] = None, - ): - """Aspirate liquid from the specified resource using pip.""" - - aspirate_parameters = [] - - assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported for now" - - indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} - for channel_idx in range(2): - if channel_idx in indexed_ops: - channel = { - 0: Prep.ChannelIndex.RearChannel, - 1: Prep.ChannelIndex.FrontChannel, - }[channel_idx] - - op = indexed_ops[channel_idx] - loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") - - assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round" - radius = op.resource.get_size_x() / 2 - - aspirate_parameters.append( - Prep.AspirateParametersNoLldAndMonitoring( - default_values=False, - channel=channel, - aspirate=Prep.AspirateParameters( - default_values=False, - x_position=loc.x, - y_position=loc.y, - prewet_volume=0.0, - blowout_volume=op.blow_out_air_volume or 0, - ), - common=Prep.CommonParameters( - default_values=False, - empty=True, - z_minimum=-5.03, # ? - z_final=z_final, - z_liquid_exit_speed=2.0, # ? - liquid_volume=op.volume, - liquid_speed=op.flow_rate or 100, # ? - transport_air_volume=0, # op.transport_air_volume, - tube_radius=radius, - cone_height=0.0, # TODO: - cone_bottom_radius=0.0, - settling_time=1.0, - additional_probes=0, - ), - no_lld=Prep.NoLldParameters( - default_values=False, - z_fluid=94.97, # ? - z_air=96.97, # ? - bottom_search=False, - z_bottom_search_offset=2.0, - z_bottom_offset=0.0, - ), - mix=Prep.MixParameters.default(), - adc=Prep.AdcParameters.default(), - aspirate_monitoring=Prep.AspirateMonitoringParameters.default(), - ) - ) - - return await self.send_command( - command_id=1, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - final_z: float = 96.97, - timeout: Optional[float] = None, - ): - """Dispense liquid from the specified resource using pip.""" - - dispense_parameters = [] - - assert len(ops) == len(use_channels) - assert max(use_channels) <= 2, "Only two channels are supported for now" - - indexed_ops = {channel_idx: op for channel_idx, op in zip(use_channels, ops)} - for channel_idx in range(2): - if channel_idx in indexed_ops: - op = indexed_ops[channel_idx] - loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") - - assert op.resource.get_size_x() == op.resource.get_size_y(), "Only round" - radius = op.resource.get_size_x() / 2 - - dispense_parameters.append( - Prep.DispenseParametersNoLld( - default_values=False, - channel={ - 0: Prep.ChannelIndex.RearChannel, - 1: Prep.ChannelIndex.FrontChannel, - }[channel_idx], - dispense=Prep.DispenseParameters( - default_values=False, - x_position=loc.x, - y_position=loc.y, - stop_back_volume=0.0, # ? - cutoff_speed=100.0, # ? - ), - common=Prep.CommonParameters( - default_values=False, - empty=True, # TODO - z_minimum=-5.03, # ? - z_final=final_z, - z_liquid_exit_speed=2.0, # ? - liquid_volume=op.volume, - liquid_speed=op.flow_rate or 100, - transport_air_volume=0, # op.transport_air_volume, - tube_radius=radius, - cone_height=0.0, # TODO - cone_bottom_radius=0, # TODO - settling_time=0.0, # TODO - additional_probes=0, # ? - ), - no_lld=Prep.NoLldParameters( - default_values=False, - z_fluid=94.97, # ? - z_air=99.08, # ? - bottom_search=False, - z_bottom_search_offset=2.0, - z_bottom_offset=0.0, - ), - mix=Prep.MixParameters.default(), - tadm=Prep.TadmParameters.default(), - adc=Prep.AdcParameters.default(), - ) - ) - - return await self.send_command( - command_id=5, - parameters=[ - (dispense_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def pick_up_tips96(self, pickup: PickupTipRack): - raise NotImplementedError("This operation is not supported on the Prep") - - async def drop_tips96(self, drop: DropTipRack): - raise NotImplementedError("This operation is not supported on the Prep") - - async def aspirate96(self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]): - raise NotImplementedError("This operation is not supported on the Prep") - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - raise NotImplementedError("This operation is not supported on the Prep") - - async def pick_up_resource(self, pickup: ResourcePickup): - raise NotImplementedError("This operation is not supported yet") - - async def move_picked_up_resource(self, move: ResourceMove): - raise NotImplementedError("This operation is not supported yet") - - async def drop_resource(self, drop: ResourceDrop): - raise NotImplementedError("This operation is not supported yet") - - # Firmware commands - - async def aspirate_tadm( - self, - aspirate_parameters: List["AspirateParametersNoLldAndTadm"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=2, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_lld( - self, - aspirate_parameters: List["AspirateParametersLldAndMonitoring"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=3, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_lld_tadm( - self, - aspirate_parameters: List["AspirateParametersLldAndTadm"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=4, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def dispense_lld( - self, - dispense_parameters: List["DispenseParametersLld"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=6, - parameters=[ - (dispense_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def dispense_initialize_to_waste( - self, - waste_parameters: List["DispenseInitToWasteParameters"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=7, - parameters=[ - (waste_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_tips_by_id( - self, - tip_parameters: List["TipPositionParameters"], - final_z: float, - seek_speed: float, - tip_definition_id: int, - enable_tadm: bool = False, - dispenser_volume: float = 0.0, - dispenser_speed: float = 250.0, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=8, - parameters=[ - (tip_parameters, ParameterTypes.StructureArray), - (final_z, ParameterTypes.Real32Bit), - (seek_speed, ParameterTypes.Real32Bit), - (tip_definition_id, ParameterTypes.UInt8Bit), - (enable_tadm, ParameterTypes.Bool), - (dispenser_volume, ParameterTypes.Real32Bit), - (dispenser_speed, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_needles_by_id( - self, - tip_parameters: List["TipPositionParameters"], - final_z: float, - seek_speed: float, - tip_definition_id: int, - blowout_offset: float = 4.0, - blowout_speed: float = 0.0, - enable_tadm: bool = False, - dispenser_volume: float = 0.0, - dispenser_speed: float = 250.0, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=10, - parameters=[ - (tip_parameters, ParameterTypes.StructureArray), - (final_z, ParameterTypes.Real32Bit), - (seek_speed, ParameterTypes.Real32Bit), - (tip_definition_id, ParameterTypes.UInt8Bit), - (blowout_offset, ParameterTypes.Real32Bit), - (blowout_speed, ParameterTypes.Real32Bit), - (enable_tadm, ParameterTypes.Bool), - (dispenser_volume, ParameterTypes.Real32Bit), - (dispenser_speed, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_needles( - self, - tip_parameters: List["TipPositionParameters"], - final_z: float, - seek_speed: float, - tip_definition: "Prep.TipPickupParameters", - blowout_offset: float = 4.0, - blowout_speed: float = 0.0, - enable_tadm: bool = False, - dispenser_volume: float = 0.0, - dispenser_speed: float = 250.0, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=11, - parameters=[ - (tip_parameters, ParameterTypes.StructureArray), - (final_z, ParameterTypes.Real32Bit), - (seek_speed, ParameterTypes.Real32Bit), - (tip_definition, ParameterTypes.Structure), - (blowout_offset, ParameterTypes.Real32Bit), - (blowout_speed, ParameterTypes.Real32Bit), - (enable_tadm, ParameterTypes.Bool), - (dispenser_volume, ParameterTypes.Real32Bit), - (dispenser_speed, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_tool_by_id( - self, - tip_definition_id: int, - tool_position_x: float, - tool_position_z: float, - front_channel_position_y: float, - rear_channel_position_y: float, - tool_seek: float, - tool_x_radius: float, - tool_y_radius: float, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=14, - parameters=[ - (tip_definition_id, ParameterTypes.UInt8Bit), - (tool_position_x, ParameterTypes.Real32Bit), - (tool_position_z, ParameterTypes.Real32Bit), - (front_channel_position_y, ParameterTypes.Real32Bit), - (rear_channel_position_y, ParameterTypes.Real32Bit), - (tool_seek, ParameterTypes.Real32Bit), - (tool_x_radius, ParameterTypes.Real32Bit), - (tool_y_radius, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_tool( - self, - tip_definition: "Prep.TipPickupParameters", - tool_position_x: float, - tool_position_z: float, - front_channel_position_y: float, - rear_channel_position_y: float, - tool_seek: float, - tool_x_radius: float, - tool_y_radius: float, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=15, - parameters=[ - (tip_definition, ParameterTypes.Structure), - (tool_position_x, ParameterTypes.Real32Bit), - (tool_position_z, ParameterTypes.Real32Bit), - (front_channel_position_y, ParameterTypes.Real32Bit), - (rear_channel_position_y, ParameterTypes.Real32Bit), - (tool_seek, ParameterTypes.Real32Bit), - (tool_x_radius, ParameterTypes.Real32Bit), - (tool_y_radius, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def drop_tool( - self, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=16, - parameters=[], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def pick_up_plate( - self, - plate_top_center: "Prep.XYZCoord", - plate: "Prep.PlateDimensions", - clearance_y: float, - grip_speed_y: float, - grip_distance: float, - grip_height: float, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=17, - parameters=[ - (plate_top_center, ParameterTypes.Structure), - (plate, ParameterTypes.Structure), - (clearance_y, ParameterTypes.Real32Bit), - (grip_speed_y, ParameterTypes.Real32Bit), - (grip_distance, ParameterTypes.Real32Bit), - (grip_height, ParameterTypes.Real32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def drop_plate( - self, - plate_top_center: "Prep.XYZCoord", - clearance_y: float, - acceleration_scale_x: int = 100, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=18, - parameters=[ - (plate_top_center, ParameterTypes.Structure), - (clearance_y, ParameterTypes.Real32Bit), - (acceleration_scale_x, ParameterTypes.UInt8Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def move_plate( - self, - plate_top_center: "Prep.XYZCoord", - acceleration_scale_x: int = 100, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=19, - parameters=[ - (plate_top_center, ParameterTypes.Structure), - (acceleration_scale_x, ParameterTypes.UInt8Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def transfer_plate( - self, - plate_source_top_center: "Prep.XYZCoord", - plate_destination_top_center: "Prep.XYZCoord", - plate: "Prep.PlateDimensions", - clearance_y: float, - grip_speed_y: float, - grip_distance: float, - grip_height: float, - acceleration_scale_x: int = 100, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=20, - parameters=[ - (plate_source_top_center, ParameterTypes.Structure), - (plate_destination_top_center, ParameterTypes.Structure), - (plate, ParameterTypes.Structure), - (clearance_y, ParameterTypes.Real32Bit), - (grip_speed_y, ParameterTypes.Real32Bit), - (grip_distance, ParameterTypes.Real32Bit), - (grip_height, ParameterTypes.Real32Bit), - (acceleration_scale_x, ParameterTypes.UInt8Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def release_plate( - self, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=21, - parameters=[], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def empty_dispenser( - self, - channels: List["ChannelIndex"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=23, - parameters=[ - (channels, ParameterTypes.EnumArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def move_to_position( - self, - move_parameters: "Prep.GantryMoveXYZParameters", - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=26, - parameters=[ - (move_parameters, ParameterTypes.Structure), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def move_to_position_via_lane( - self, - move_parameters: "Prep.GantryMoveXYZParameters", - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=27, - parameters=[ - (move_parameters, ParameterTypes.Structure), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def move_z_up_to_safe( - self, - channels: List["ChannelIndex"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=28, - parameters=[ - (channels, ParameterTypes.EnumArray), - ], - timeout=timeout, - # harp_source=self.pipettor_source, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0007, 0x0006)), - harp_destination=self.pipettor_destination, - ) - - async def z_seek_lld_position( - self, - seek_parameters: List["LLDChannelSeekParameters"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=29, - parameters=[ - (seek_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def create_tadm_limit_curve( - self, - channel: "Prep.ChannelIndex", - name: str, - lower_limit: List["LimitCurveEntry"], - upper_limit: List["LimitCurveEntry"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=31, - parameters=[ - (channel, ParameterTypes.UInt32Bit), - (name, ParameterTypes.String), - (lower_limit, ParameterTypes.StructureArray), - (upper_limit, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def erase_tadm_limit_curves( - self, - channel: "Prep.ChannelIndex", - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=32, - parameters=[ - (channel, ParameterTypes.UInt32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def get_tadm_limit_curve_names( - self, - channel: "Prep.ChannelIndex", - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=33, - parameters=[ - (channel, ParameterTypes.UInt32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def get_tadm_limit_curve_info( - self, - channel: "Prep.ChannelIndex", - name: str, - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=34, - parameters=[ - (channel, ParameterTypes.UInt32Bit), - (name, ParameterTypes.String), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def retrieve_tadm_data( - self, - channel: "Prep.ChannelIndex", - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=35, - parameters=[ - (channel, ParameterTypes.UInt32Bit), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def reset_tadm_fifo( - self, - channels: List["ChannelIndex"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=36, - parameters=[ - (channels, ParameterTypes.EnumArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_v2( - self, - aspirate_parameters: List["AspirateParametersNoLldAndMonitoring2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=38, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_tadm_v2( - self, - aspirate_parameters: List["AspirateParametersNoLldAndTadm2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=39, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_lld_v2( - self, - aspirate_parameters: List["AspirateParametersLldAndMonitoring2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=40, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def aspirate_lld_tadm_v2( - self, - aspirate_parameters: List["AspirateParametersLldAndTadm2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=41, - parameters=[ - (aspirate_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def dispense_v2( - self, - dispense_parameters: List["DispenseParametersNoLld2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=42, - parameters=[ - (dispense_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def dispense_lld_v2( - self, - dispense_parameters: List["DispenseParametersLld2"], - timeout: Optional[float] = None, - ) -> bytes: - return await self.send_command( - command_id=43, - parameters=[ - (dispense_parameters, ParameterTypes.StructureArray), - ], - timeout=timeout, - harp_source=self.pipettor_source, - harp_destination=self.pipettor_destination, - ) - - async def initialize( - self, - tip_drop_params: InitTipDropParameters, - smart: bool = False, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=1, - parameters=[ - (smart, ParameterTypes.Bool), - (tip_drop_params, ParameterTypes.Structure), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def park( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=3, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def spread( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=4, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def add_tip_and_needle_definition( - self, - parameters_: "Prep.TipDefinition", - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=12, - parameters=[ - (parameters_, ParameterTypes.Structure), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def remove_tip_and_needle_definition( - self, - id_: int, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=13, - parameters=[ - (id_, ParameterTypes.Enum), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def read_storage( - self, - offset: int, - length: int, - timeout: Optional[float] = None, - ) -> bytes: - result = await self.send_command( - command_id=14, - parameters=[ - (offset, ParameterTypes.UInt32Bit), - (length, ParameterTypes.UInt32Bit), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - return result - - async def write_storage( - self, - offset: int, - data: bytes, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=15, - parameters=[ - (offset, ParameterTypes.UInt32Bit), - (data, ParameterTypes.UInt8Array), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def power_down_request( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=17, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def confirm_power_down( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=18, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def cancel_power_down( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=19, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def remove_channel_power_for_head_swap( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=23, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def restore_channel_power_after_head_swap( - self, - delay_ms: int, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=24, - parameters=[ - (delay_ms, ParameterTypes.UInt32Bit), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def set_deck_light( - self, - white: int, - red: int, - green: int, - blue: int, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=25, - parameters=[ - (white, ParameterTypes.UInt8Bit), - (red, ParameterTypes.UInt8Bit), - (green, ParameterTypes.UInt8Bit), - (blue, ParameterTypes.UInt8Bit), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0005, 0x0002)), - harp_destination=self.destination_address, - ) - - async def disco_mode(self): - """Easter egg""" - for _ in range(69): - await self.set_deck_light( - white=random.randint(1, 255), - red=random.randint(1, 255), - green=random.randint(1, 255), - blue=random.randint(1, 255), - ) - await asyncio.sleep(0.1) - - async def get_deck_light( - self, - timeout: Optional[float] = None, - ) -> "Tuple[int, int, int, int]": - result = await self.send_command( - command_id=26, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - if len(result) != 4: - raise ValueError("Invalid return length for deck light data.") - white, red, green, blue = result - return white, red, green, blue - - async def suspended_park( - self, - move_parameters: "Prep.GantryMoveXYZParameters", - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=29, - parameters=[ - (move_parameters, ParameterTypes.Structure), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def method_begin( - self, - automatic_pause: bool = False, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=30, - parameters=[ - (automatic_pause, ParameterTypes.Bool), - ], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def method_end( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=31, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def method_abort( - self, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=33, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - - async def is_parked( - self, - timeout: Optional[float] = None, - ) -> bool: - result = await self.send_command( - command_id=34, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - if len(result) != 1: - raise ValueError("Invalid return length for is_parked status.") - return bool(result[0]) - - async def is_spread( - self, - timeout: Optional[float] = None, - ) -> bool: - result = await self.send_command( - command_id=35, - parameters=[], - timeout=timeout, - harp_source=self.source_address, - harp_destination=self.destination_address, - ) - if len(result) != 1: - raise ValueError("Invalid return length for is_spread status.") - return bool(result[0]) - - # custom - - async def z_travel_configuration( - self, - unknown: int, - timeout: Optional[float] = None, - ) -> None: - return await self.send_command( - command_id=13, - parameters=[ - (unknown, ParameterTypes.Enum), - ], - timeout=timeout, - harp_source=Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0005)), - harp_destination=Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBEF0)), - ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index b548844d318..c2eb0fab399 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -9,7 +9,7 @@ (and optionally port) for default TCP settings, or pass a pre-configured HamiltonTCPClient for full control. -- **Command dataclasses** (e.g. ``PrepDropTips``, ``MphPickupTips``): Pure wire shapes. +- **Command dataclasses** (e.g. ``PrepCmd.PrepDropTips``, ``PrepCmd.MphPickupTips``): Pure wire shapes. Defined in ``prep_commands.py``; ``@dataclass`` with ``dest: Address`` + ``Annotated`` payload fields; ``build_parameters()`` uses ``HoiParams.from_struct(self)``. @@ -28,7 +28,7 @@ import random from typing import List, Optional, overload, Tuple, Union -from pylabrobot.liquid_handling.backends.hamilton.prep_commands import * # noqa: F401,F403 +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults @@ -37,6 +37,7 @@ HamiltonInterfaceResolver, InterfaceSpec, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -69,7 +70,7 @@ def _effective_radius(resource) -> float: - """Effective radius for CommonParameters.tube_radius. + """Effective radius for PrepCmd.CommonParameters.tube_radius. For circular wells uses the actual radius; for rectangular wells computes the radius of a circle with equivalent area so tube_radius is meaningful to the @@ -80,15 +81,15 @@ def _effective_radius(resource) -> float: return float(resource.get_size_x() / 2) -def _build_container_segments(resource) -> list[SegmentDescriptor]: - """Derive SegmentDescriptor list from a Well's geometry for liquid-following. +def _build_container_segments(resource) -> list[PrepCmd.SegmentDescriptor]: + """Derive PrepCmd.SegmentDescriptor list from a Well's geometry for liquid-following. Each segment is a frustum. The firmware uses area_bottom/area_top to interpolate cross-sectional area A(z) within the segment and computes the Z-axis following speed as dz/dt = Q / A(z), where Q is volumetric flow rate. Returns [] when geometry cannot be determined; the firmware then falls back to - the tube_radius / cone model in CommonParameters. + the tube_radius / cone model in PrepCmd.CommonParameters. """ if not isinstance(resource, Well): return [] @@ -115,7 +116,7 @@ def area_at(h: float) -> float: return dv / (h_hi - h_lo) return [ - SegmentDescriptor( + PrepCmd.SegmentDescriptor( area_top=float(area_at(heights[i + 1])), area_bottom=float(area_at(heights[i])), height=float(heights[i + 1] - heights[i]), @@ -124,7 +125,9 @@ def area_at(h: float) -> float: ] # Simple geometry: single segment with constant cross-section. - return [SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z))] + return [ + PrepCmd.SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z)) + ] def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): @@ -153,8 +156,8 @@ def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): # ============================================================================= _CHANNEL_INDEX = { - 0: ChannelIndex.RearChannel, - 1: ChannelIndex.FrontChannel, + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, } # Channel index -> deck waste resource name (PrepDeck: waste_rear, waste_front, waste_mph) @@ -182,11 +185,11 @@ class PrepBackend(LiquidHandlerBackend): # Declare known object paths via InterfaceSpec. deck_config required (key positions, traverse height, deck info). _INTERFACES: dict[str, InterfaceSpec] = { - "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), - "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), - "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), - "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), - "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), + "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), + "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), + "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), + "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), + "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), "mlprep_service": InterfaceSpec("MLPrepRoot.MLPrepService", False, True), } @@ -233,7 +236,7 @@ def __init__( self.client = HamiltonTCPClient(host=host, port=port) else: raise TypeError("Provide either host or client") - self._config: Optional[InstrumentConfig] = None + self._config: Optional[PrepCmd.InstrumentConfig] = None self._user_traverse_height: Optional[float] = default_traverse_height self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) self._num_channels: Optional[int] = None @@ -260,10 +263,10 @@ async def _require(self, name: str) -> Address: """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" return await self._resolver.require(name) - async def get_present_channels(self) -> Optional[Tuple[ChannelIndex, ...]]: + async def get_present_channels(self) -> Optional[Tuple[PrepCmd.ChannelIndex, ...]]: """Query which channels are present (GetPresentChannels on MLPrepService). - Maps raw enum values to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, + Maps raw enum values to PrepCmd.ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. Returns None if MLPrepService is unavailable or the command fails (caller should use defaults). """ @@ -271,11 +274,11 @@ async def get_present_channels(self) -> Optional[Tuple[ChannelIndex, ...]]: return None try: service_addr = await self._require("mlprep_service") - resp = await self.client.send_command(PrepGetPresentChannels(dest=service_addr)) + resp = await self.client.send_command(PrepCmd.PrepGetPresentChannels(dest=service_addr)) if resp is None or not getattr(resp, "channels", None): return None present = tuple( - ChannelIndex(v) if v in (0, 1, 2, 3) else ChannelIndex.InvalidIndex + PrepCmd.ChannelIndex(v) if v in (0, 1, 2, 3) else PrepCmd.ChannelIndex.InvalidIndex for v in resp.channels ) return present @@ -345,12 +348,12 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self.setup_finished = True async def _run_initialize(self, smart: bool): - """Send PrepInitialize to MLPrep (shared by setup).""" + """Send PrepCmd.PrepInitialize to MLPrep (shared by setup).""" await self.client.send_command( - PrepInitialize( + PrepCmd.PrepInitialize( dest=await self._require("mlprep"), smart=smart, - tip_drop_params=InitTipDropParameters( + tip_drop_params=PrepCmd.InitTipDropParameters( default_values=True, x_position=287.0, rolloff_distance=3, @@ -359,28 +362,28 @@ async def _run_initialize(self, smart: bool): ) ) - async def _get_hardware_config(self) -> InstrumentConfig: + async def _get_hardware_config(self) -> PrepCmd.InstrumentConfig: """Aggregate getters: query MLPrep, DeckConfiguration, and MLPrepService for hardware config. Includes deck/enclosure, deck sites, waste sites, traverse height, and channel configuration (num_channels, has_mph) from GetPresentChannels. """ mlprep = await self._require("mlprep") - enc_resp = await self.client.send_command(PrepGetIsEnclosurePresent(dest=mlprep)) - safe_resp = await self.client.send_command(PrepGetSafeSpeedsEnabled(dest=mlprep)) - height_resp = await self.client.send_command(PrepGetDefaultTraverseHeight(dest=mlprep)) + enc_resp = await self.client.send_command(PrepCmd.PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await self.client.send_command(PrepCmd.PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await self.client.send_command(PrepCmd.PrepGetDefaultTraverseHeight(dest=mlprep)) has_enclosure = bool(enc_resp.value) if enc_resp else False safe_speeds_enabled = bool(safe_resp.value) if safe_resp else False default_traverse_height = float(height_resp.value) if height_resp else None - deck_bounds: Optional[DeckBounds] = None - deck_sites: Tuple[DeckSiteInfo, ...] = () - waste_sites: Tuple[WasteSiteInfo, ...] = () + deck_bounds: Optional[PrepCmd.DeckBounds] = None + deck_sites: Tuple[PrepCmd.DeckSiteInfo, ...] = () + waste_sites: Tuple[PrepCmd.WasteSiteInfo, ...] = () deck_addr = await self._require("deck_config") - bounds_resp = await self.client.send_command(PrepGetDeckBounds(dest=deck_addr)) + bounds_resp = await self.client.send_command(PrepCmd.PrepGetDeckBounds(dest=deck_addr)) if bounds_resp: - deck_bounds = DeckBounds( + deck_bounds = PrepCmd.DeckBounds( min_x=bounds_resp.min_x, max_x=bounds_resp.max_x, min_y=bounds_resp.min_y, @@ -389,10 +392,10 @@ async def _get_hardware_config(self) -> InstrumentConfig: max_z=bounds_resp.max_z, ) - sites_resp = await self.client.send_command(PrepGetDeckSiteDefinitions(dest=deck_addr)) + sites_resp = await self.client.send_command(PrepCmd.PrepGetDeckSiteDefinitions(dest=deck_addr)) if sites_resp and sites_resp.sites: deck_sites = tuple( - DeckSiteInfo( + PrepCmd.DeckSiteInfo( id=int(s.id), left_bottom_front_x=float(s.left_bottom_front_x), left_bottom_front_y=float(s.left_bottom_front_y), @@ -405,10 +408,10 @@ async def _get_hardware_config(self) -> InstrumentConfig: ) logger.debug("Discovered %d deck sites", len(deck_sites)) - waste_resp = await self.client.send_command(PrepGetWasteSiteDefinitions(dest=deck_addr)) + waste_resp = await self.client.send_command(PrepCmd.PrepGetWasteSiteDefinitions(dest=deck_addr)) if waste_resp and waste_resp.sites: waste_sites = tuple( - WasteSiteInfo( + PrepCmd.WasteSiteInfo( index=int(s.index), x_position=float(s.x_position), y_position=float(s.y_position), @@ -422,14 +425,18 @@ async def _get_hardware_config(self) -> InstrumentConfig: # Channel configuration (1 vs 2 dual-channel pipettor, 8MPH) from MLPrepService present = await self.get_present_channels() if present is not None: - dual = [c for c in present if c in (ChannelIndex.FrontChannel, ChannelIndex.RearChannel)] + dual = [ + c + for c in present + if c in (PrepCmd.ChannelIndex.FrontChannel, PrepCmd.ChannelIndex.RearChannel) + ] num_channels = len(dual) - has_mph = ChannelIndex.MPHChannel in present + has_mph = PrepCmd.ChannelIndex.MPHChannel in present else: num_channels = 2 has_mph = False - return InstrumentConfig( + return PrepCmd.InstrumentConfig( deck_bounds=deck_bounds, has_enclosure=has_enclosure, safe_speeds_enabled=safe_speeds_enabled, @@ -487,17 +494,19 @@ async def is_initialized(self) -> bool: Uses MLPrep method from introspection: GetIsInitialized(()) -> value: I64. Requires MLPrep to be discovered (e.g. after self.client.setup() and - _discover_prep_objects()). Call before or after PrepInitialize to test. + _discover_prep_objects()). Call before or after PrepCmd.PrepInitialize to test. """ - result = await self.client.send_command(PrepGetIsInitialized(dest=await self._require("mlprep"))) + result = await self.client.send_command( + PrepCmd.PrepGetIsInitialized(dest=await self._require("mlprep")) + ) if result is None: return False return bool(result.value) - async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: + async def get_tip_and_needle_definitions(self) -> Tuple[PrepCmd.TipDefinition, ...]: """Return tip/needle definitions registered on the instrument (GetTipAndNeedleDefinitions, cmd=11).""" result = await self.client.send_command( - PrepGetTipAndNeedleDefinitions(dest=await self._require("mlprep")) + PrepCmd.PrepGetTipAndNeedleDefinitions(dest=await self._require("mlprep")) ) if result is None or not getattr(result, "definitions", None): return () @@ -505,14 +514,18 @@ async def get_tip_and_needle_definitions(self) -> Tuple[TipDefinition, ...]: async def is_parked(self) -> bool: """Query whether MLPrep is parked (IsParked, cmd=34).""" - result = await self.client.send_command(PrepIsParked(dest=await self._require("mlprep"))) + result = await self.client.send_command( + PrepCmd.PrepIsParked(dest=await self._require("mlprep")) + ) if result is None: return False return bool(result.value) async def is_spread(self) -> bool: """Query whether channels are spread (IsSpread, cmd=35). Pipettor commands typically require spread state.""" - result = await self.client.send_command(PrepIsSpread(dest=await self._require("mlprep"))) + result = await self.client.send_command( + PrepCmd.PrepIsSpread(dest=await self._require("mlprep")) + ) if result is None: return False return bool(result.value) @@ -558,32 +571,34 @@ async def pick_up_tips( resolved_final_z = self._resolve_traverse_height(final_z) indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - tip_positions: List[TipPositionParameters] = [] + tip_positions: List[PrepCmd.TipPositionParameters] = [] for ch in range(self.num_channels): if ch not in indexed_ops: continue op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "t") - params = TipPositionParameters.for_op( - _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), + params = PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + op.resource.get_tip(), z_seek_offset=z_seek_offset, ) tip_positions.append(params) assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip type" tip = ops[0].tip - tip_definition = TipPickupParameters( + tip_definition = PrepCmd.TipPickupParameters( default_values=False, volume=tip.maximal_volume, length=tip.total_tip_length - tip.fitting_depth, - tip_type=TipTypes.StandardVolume, + tip_type=PrepCmd.TipTypes.StandardVolume, has_filter=tip.has_filter, is_needle=False, is_tool=False, ) await self.client.send_command( - PrepPickUpTips( + PrepCmd.PrepPickUpTips( dest=await self._require("pipettor"), tip_positions=tip_positions, final_z=resolved_final_z, @@ -602,7 +617,7 @@ async def drop_tips( final_z: Optional[float] = None, seek_speed: float = 30.0, z_seek_offset: Optional[float] = None, - drop_type: TipDropType = TipDropType.FixedHeight, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, tip_roll_off_distance: float = 0.0, ): """Drop tips. @@ -631,17 +646,15 @@ async def drop_tips( all_trash = all(isinstance(op.resource, Trash) for op in ops) all_tip_spots = all(isinstance(op.resource, TipSpot) for op in ops) if not (all_trash or all_tip_spots): - raise ValueError( - "Cannot mix waste (Trash) and tip spots in a single drop_tips call." - ) + raise ValueError("Cannot mix waste (Trash) and tip spots in a single drop_tips call.") resolved_final_z = self._resolve_traverse_height(final_z) roll_off = 3.0 if (all_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance # Use Stall when dropping to waste so the pipette detects contact before release. - resolved_drop_type = TipDropType.Stall if all_trash else drop_type + resolved_drop_type = PrepCmd.TipDropType.Stall if all_trash else drop_type indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} - tip_positions: List[TipDropParameters] = [] + tip_positions: List[PrepCmd.TipDropParameters] = [] for ch in range(self.num_channels): if ch not in indexed_ops: continue @@ -657,15 +670,17 @@ async def drop_tips( loc = self.deck.get_resource(waste_name).get_absolute_location("c", "c", "t") else: loc = op.resource.get_absolute_location("c", "c", "t") + op.offset - params = TipDropParameters.for_op( - _CHANNEL_INDEX[ch], loc, tip, + params = PrepCmd.TipDropParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + tip, z_seek_offset=z_seek_offset, drop_type=resolved_drop_type, ) tip_positions.append(params) await self.client.send_command( - PrepDropTips( + PrepCmd.PrepDropTips( dest=await self._require("pipettor"), tip_positions=tip_positions, final_z=resolved_final_z, @@ -727,22 +742,22 @@ async def pick_up_tips_mph( ref_spot = spots[0] tip = ref_spot.get_tip() loc = ref_spot.get_absolute_location("c", "c", "t") - tip_parameters = TipPositionParameters.for_op( - ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset + tip_parameters = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset ) - tip_definition = TipPickupParameters( + tip_definition = PrepCmd.TipPickupParameters( default_values=False, volume=tip.maximal_volume, length=tip.total_tip_length - tip.fitting_depth, - tip_type=TipTypes.StandardVolume, + tip_type=PrepCmd.TipTypes.StandardVolume, has_filter=tip.has_filter, is_needle=False, is_tool=False, ) await self.client.send_command( - MphPickupTips( + PrepCmd.MphPickupTips( dest=await self._require("mph"), tip_parameters=tip_parameters, final_z=resolved_final_z, @@ -761,7 +776,7 @@ async def drop_tips_mph( final_z: Optional[float] = None, seek_speed: float = 30.0, z_seek_offset: Optional[float] = None, - drop_type: TipDropType = TipDropType.FixedHeight, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, tip_roll_off_distance: float = 0.0, ) -> None: """Drop tips held by the MPH head. @@ -794,14 +809,16 @@ async def drop_tips_mph( ref_spot = spots[0] tip = ref_spot.get_tip() loc = ref_spot.get_absolute_location("c", "c", "t") - drop_parameters = TipDropParameters.for_op( - ChannelIndex.MPHChannel, loc, tip, + drop_parameters = PrepCmd.TipDropParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + tip, z_seek_offset=z_seek_offset, drop_type=drop_type, ) await self.client.send_command( - MphDropTips( + PrepCmd.MphDropTips( dest=await self._require("mph"), drop_parameters=drop_parameters, final_z=resolved_final_z, @@ -823,13 +840,15 @@ async def aspirate( prewet_volume: Optional[List[float]] = None, z_minimum: Optional[List[float]] = None, z_bottom_search_offset: Optional[List[float]] = None, - monitoring_mode: MonitoringMode = MonitoringMode.MONITORING, + monitoring_mode: PrepCmd.MonitoringMode = PrepCmd.MonitoringMode.MONITORING, use_lld: bool = False, - lld: Optional[LldParameters] = None, - p_lld: Optional[PLldParameters] = None, - c_lld: Optional[CLldParameters] = None, - tadm: Optional[TadmParameters] = None, - container_segments: Optional[List[List[SegmentDescriptor]]] = None, # TODO: Doesn't work with No LLD + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[ + List[List[PrepCmd.SegmentDescriptor]] + ] = None, # TODO: Doesn't work with No LLD auto_container_geometry: bool = False, hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, disable_volume_correction: Optional[List[bool]] = None, @@ -865,11 +884,11 @@ async def aspirate( p_lld: Pressure LLD parameters (LLD variants only). c_lld: Capacitive LLD parameters (LLD variants only). tadm: TADM parameters (TADM variants only). Firmware defaults when None. - container_segments: Per-channel SegmentDescriptor lists for liquid following. + container_segments: Per-channel PrepCmd.SegmentDescriptor lists for liquid following. If None and auto_container_geometry=True, derived from well geometry. auto_container_geometry: Automatically build container segments from the well's cross-section geometry. Pass False to use empty segments - (firmware falls back to the CommonParameters cone model). + (firmware falls back to the PrepCmd.CommonParameters cone model). hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. @@ -878,7 +897,7 @@ async def aspirate( await backend.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) await backend.aspirate(ops, [0], use_lld=True) - await backend.aspirate(ops, [0], monitoring_mode=MonitoringMode.TADM) + await backend.aspirate(ops, [0], monitoring_mode=PrepCmd.MonitoringMode.TADM) """ assert len(ops) == len(use_channels) if use_channels: @@ -908,7 +927,9 @@ async def aspirate( ) for op in ops ] - disable_volume_correction = disable_volume_correction if disable_volume_correction is not None else [False] * n + disable_volume_correction = ( + disable_volume_correction if disable_volume_correction is not None else [False] * n + ) if len(disable_volume_correction) != n: raise ValueError( f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" @@ -917,9 +938,15 @@ async def aspirate( # Default lists from HLC (fallbacks when HLC is None) default_settling = [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] - default_transport_air_volume = [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs] - default_z_liquid_exit_speed = [hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs] - default_prewet_volume = [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs] + default_transport_air_volume = [ + hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_z_liquid_exit_speed = [ + hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs + ] + default_prewet_volume = [ + hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs + ] settling_time = fill_in_defaults(settling_time, default_settling) transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) @@ -955,10 +982,12 @@ async def aspirate( z_fluid = fill_in_defaults(z_fluid, default_z_fluid) z_air = fill_in_defaults(z_air, default_z_air) z_final = fill_in_defaults(z_final, default_z_final) - z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, default_z_bottom_search_offset) + z_bottom_search_offset = fill_in_defaults( + z_bottom_search_offset, default_z_bottom_search_offset + ) # Build per-channel segment lists. - ch_segments: dict[int, list[SegmentDescriptor]] = {} + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] = {} for i, ch in enumerate(use_channels): if container_segments is not None and i < len(container_segments): ch_segments[ch] = container_segments[i] @@ -967,14 +996,14 @@ async def aspirate( else: ch_segments[ch] = [] - _p_lld = p_lld or PLldParameters.default() - _c_lld = c_lld or CLldParameters.default() - _tadm = tadm or TadmParameters.default() + _p_lld = p_lld or PrepCmd.PLldParameters.default() + _c_lld = c_lld or PrepCmd.CLldParameters.default() + _tadm = tadm or PrepCmd.TadmParameters.default() - params_lld_mon: List[AspirateParametersLldAndMonitoring2] = [] - params_lld_tadm: List[AspirateParametersLldAndTadm2] = [] - params_nolld_mon: List[AspirateParametersNoLldAndMonitoring2] = [] - params_nolld_tadm: List[AspirateParametersNoLldAndTadm2] = [] + params_lld_mon: List[PrepCmd.AspirateParametersLldAndMonitoring2] = [] + params_lld_tadm: List[PrepCmd.AspirateParametersLldAndTadm2] = [] + params_nolld_mon: List[PrepCmd.AspirateParametersNoLldAndMonitoring2] = [] + params_nolld_tadm: List[PrepCmd.AspirateParametersNoLldAndTadm2] = [] for ch in range(self.num_channels): if ch not in indexed_ops: @@ -983,7 +1012,7 @@ async def aspirate( op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") radius = _effective_radius(op.resource) - asp = AspirateParameters.for_op( + asp = PrepCmd.AspirateParameters.for_op( loc, op, prewet_volume=prewet_volume[idx], @@ -999,7 +1028,7 @@ async def aspirate( if effective_lld and lld is None: top_of_well_z = well_geometry[idx][2] - _lld = LldParameters( + _lld = PrepCmd.LldParameters( default_values=False, z_seek=top_of_well_z, z_seek_speed=0.0, @@ -1007,9 +1036,9 @@ async def aspirate( z_out_of_liquid=0.0, ) else: - _lld = lld or LldParameters.default() + _lld = lld or PrepCmd.LldParameters.default() - common = CommonParameters.for_op( + common = PrepCmd.CommonParameters.for_op( volumes[idx], radius, flow_rate=flow_rates[idx], @@ -1019,66 +1048,88 @@ async def aspirate( transport_air_volume=transport_air_volume[idx], settling_time=settling_time[idx], ) - no_lld = NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch + ) - if effective_lld and monitoring_mode == MonitoringMode.TADM: - params_lld_tadm.append(AspirateParametersLldAndTadm2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - aspirate=asp, - container_description=segs, - common=common, - lld=_lld, p_lld=_p_lld, c_lld=_c_lld, - mix=MixParameters.default(), - tadm=_tadm, - adc=AdcParameters.default(), - )) + if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: + params_lld_tadm.append( + PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, + p_lld=_p_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + tadm=_tadm, + adc=PrepCmd.AdcParameters.default(), + ) + ) elif effective_lld: - params_lld_mon.append(AspirateParametersLldAndMonitoring2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - aspirate=asp, - container_description=segs, - common=common, - lld=_lld, p_lld=_p_lld, c_lld=_c_lld, - mix=MixParameters.default(), - aspirate_monitoring=AspirateMonitoringParameters.default(), - adc=AdcParameters.default(), - )) - elif monitoring_mode == MonitoringMode.TADM: - params_nolld_tadm.append(AspirateParametersNoLldAndTadm2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - aspirate=asp, - container_description=segs, - common=common, - no_lld=no_lld, - mix=MixParameters.default(), - adc=AdcParameters.default(), - tadm=_tadm, - )) + params_lld_mon.append( + PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, + p_lld=_p_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + elif monitoring_mode == PrepCmd.MonitoringMode.TADM: + params_nolld_tadm.append( + PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=_tadm, + ) + ) else: - params_nolld_mon.append(AspirateParametersNoLldAndMonitoring2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - aspirate=asp, - container_description=segs, - common=common, - no_lld=no_lld, - mix=MixParameters.default(), - adc=AdcParameters.default(), - aspirate_monitoring=AspirateMonitoringParameters.default(), - )) + params_nolld_mon.append( + PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + ) dest = await self._require("pipettor") - if effective_lld and monitoring_mode == MonitoringMode.TADM: - await self.client.send_command(PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm)) + if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: + await self.client.send_command( + PrepCmd.PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm) + ) elif effective_lld: - await self.client.send_command(PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon)) - elif monitoring_mode == MonitoringMode.TADM: - await self.client.send_command(PrepAspirateTadmV2(dest=dest, aspirate_parameters=params_nolld_tadm)) + await self.client.send_command( + PrepCmd.PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon) + ) + elif monitoring_mode == PrepCmd.MonitoringMode.TADM: + await self.client.send_command( + PrepCmd.PrepAspirateTadmV2(dest=dest, aspirate_parameters=params_nolld_tadm) + ) else: - await self.client.send_command(PrepAspirateNoLldMonitoringV2(dest=dest, aspirate_parameters=params_nolld_mon)) + await self.client.send_command( + PrepCmd.PrepAspirateNoLldMonitoringV2(dest=dest, aspirate_parameters=params_nolld_mon) + ) async def dispense( self, @@ -1095,10 +1146,10 @@ async def dispense( z_minimum: Optional[List[float]] = None, z_bottom_search_offset: Optional[List[float]] = None, use_lld: bool = False, - lld: Optional[LldParameters] = None, - c_lld: Optional[CLldParameters] = None, - container_segments: Optional[List[List[SegmentDescriptor]]] = None, - auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, disable_volume_correction: Optional[List[bool]] = None, ): @@ -1128,7 +1179,7 @@ async def dispense( use_lld: Enable LLD dispense variant. Also activated if ``lld`` is set. lld: LLD seek parameters. When None and use_lld=True, built from labware geometry. c_lld: Capacitive LLD parameters (LLD variant only). - container_segments: Per-channel SegmentDescriptor lists for liquid following. + container_segments: Per-channel PrepCmd.SegmentDescriptor lists for liquid following. auto_container_geometry: Automatically build container segments from well geometry. hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. @@ -1167,7 +1218,9 @@ async def dispense( ) for op in ops ] - disable_volume_correction = disable_volume_correction if disable_volume_correction is not None else [False] * n + disable_volume_correction = ( + disable_volume_correction if disable_volume_correction is not None else [False] * n + ) if len(disable_volume_correction) != n: raise ValueError( f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" @@ -1176,10 +1229,18 @@ async def dispense( # Default lists from HLC (fallbacks when HLC is None) default_settling = [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] - default_transport_air_volume = [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs] - default_z_liquid_exit_speed = [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs] - default_stop_back_volume = [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs] - default_cutoff_speed = [hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs] + default_transport_air_volume = [ + hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_z_liquid_exit_speed = [ + hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs + ] + default_stop_back_volume = [ + hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_cutoff_speed = [ + hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs + ] settling_time = fill_in_defaults(settling_time, default_settling) transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) @@ -1212,9 +1273,11 @@ async def dispense( z_fluid = fill_in_defaults(z_fluid, default_z_fluid) z_air = fill_in_defaults(z_air, default_z_air) final_z = fill_in_defaults(final_z, default_final_z) - z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, default_z_bottom_search_offset) + z_bottom_search_offset = fill_in_defaults( + z_bottom_search_offset, default_z_bottom_search_offset + ) - ch_segments: dict[int, list[SegmentDescriptor]] = {} + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] = {} for i, ch in enumerate(use_channels): if container_segments is not None and i < len(container_segments): ch_segments[ch] = container_segments[i] @@ -1223,10 +1286,10 @@ async def dispense( else: ch_segments[ch] = [] - _c_lld = c_lld or CLldParameters.default() + _c_lld = c_lld or PrepCmd.CLldParameters.default() - params_nolld: List[DispenseParametersNoLld2] = [] - params_lld: List[DispenseParametersLld2] = [] + params_nolld: List[PrepCmd.DispenseParametersNoLld2] = [] + params_lld: List[PrepCmd.DispenseParametersLld2] = [] for ch in range(self.num_channels): if ch not in indexed_ops: @@ -1235,7 +1298,7 @@ async def dispense( op = indexed_ops[ch] loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") radius = _effective_radius(op.resource) - disp = DispenseParameters.for_op( + disp = PrepCmd.DispenseParameters.for_op( loc, stop_back_volume=stop_back_volume[idx], cutoff_speed=cutoff_speed[idx], @@ -1250,7 +1313,7 @@ async def dispense( if effective_lld and lld is None: top_of_well_z = well_geometry[idx][2] - _lld = LldParameters( + _lld = PrepCmd.LldParameters( default_values=False, z_seek=top_of_well_z, z_seek_speed=0.0, @@ -1258,9 +1321,9 @@ async def dispense( z_out_of_liquid=0.0, ) else: - _lld = lld or LldParameters.default() + _lld = lld or PrepCmd.LldParameters.default() - common = CommonParameters.for_op( + common = PrepCmd.CommonParameters.for_op( volumes[idx], radius, flow_rate=flow_rates[idx], @@ -1272,36 +1335,46 @@ async def dispense( ) if effective_lld: - params_lld.append(DispenseParametersLld2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - dispense=disp, - container_description=segs, - common=common, - lld=_lld, - c_lld=_c_lld, - mix=MixParameters.default(), - adc=AdcParameters.default(), - tadm=TadmParameters.default(), - )) + params_lld.append( + PrepCmd.DispenseParametersLld2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=disp, + container_description=segs, + common=common, + lld=_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=PrepCmd.TadmParameters.default(), + ) + ) else: - params_nolld.append(DispenseParametersNoLld2( - default_values=False, - channel=_CHANNEL_INDEX[ch], - dispense=disp, - container_description=segs, - common=common, - no_lld=NoLldParameters.for_fixed_z(z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch), - mix=MixParameters.default(), - adc=AdcParameters.default(), - tadm=TadmParameters.default(), - )) + params_nolld.append( + PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=disp, + container_description=segs, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch + ), + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=PrepCmd.TadmParameters.default(), + ) + ) dest = await self._require("pipettor") if effective_lld: - await self.client.send_command(PrepDispenseWithLldV2(dest=dest, dispense_parameters=params_lld)) + await self.client.send_command( + PrepCmd.PrepDispenseWithLldV2(dest=dest, dispense_parameters=params_lld) + ) else: - await self.client.send_command(PrepDispenseNoLldV2(dest=dest, dispense_parameters=params_nolld)) + await self.client.send_command( + PrepCmd.PrepDispenseNoLldV2(dest=dest, dispense_parameters=params_nolld) + ) async def pick_up_tips96(self, pickup: PickupTipRack): raise NotImplementedError("pick_up_tips96 is not supported on the Prep") @@ -1314,9 +1387,7 @@ async def aspirate96( ): raise NotImplementedError("aspirate96 is not supported on the Prep") - async def dispense96( - self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] - ): + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): raise NotImplementedError("dispense96 is not supported on the Prep") async def pick_up_tool( @@ -1329,15 +1400,15 @@ async def pick_up_tool( tool_seek: Optional[float] = None, tool_x_radius: float = 2.0, tool_y_radius: float = 2.0, - tip_definition: Optional[TipPickupParameters] = None, + tip_definition: Optional[PrepCmd.TipPickupParameters] = None, ) -> None: - """Pick up tool from the given position (PrepPickUpTool, cmd=15). Sets _gripper_tool_on and moves channels to safe Z.""" + """Pick up tool from the given position (PrepCmd.PrepPickUpTool, cmd=15). Sets _gripper_tool_on and moves channels to safe Z.""" if tool_seek is None: tool_seek = tool_position_z + 10.0 if tip_definition is None: - tip_definition = CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + tip_definition = PrepCmd.CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS await self.client.send_command( - PrepPickUpTool( + PrepCmd.PrepPickUpTool( dest=await self._require("pipettor"), tip_definition=tip_definition, tool_position_x=tool_position_x, @@ -1352,22 +1423,16 @@ async def pick_up_tool( self._gripper_tool_on = True await self.move_channels_to_safe_z() - async def drop_tool( - self, *, move_to_safe_z_first: bool = True - ) -> None: - """Drop tool (PrepDropTool, cmd=16). Optionally move channels to safe Z first. Clears _gripper_tool_on.""" + async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: + """Drop tool (PrepCmd.PrepDropTool, cmd=16). Optionally move channels to safe Z first. Clears _gripper_tool_on.""" if move_to_safe_z_first: await self.move_channels_to_safe_z() - await self.client.send_command( - PrepDropTool(dest=await self._require("pipettor")) - ) + await self.client.send_command(PrepCmd.PrepDropTool(dest=await self._require("pipettor"))) self._gripper_tool_on = False async def release_plate(self) -> None: - """Release plate / open gripper (PrepReleasePlate, cmd=21). No parameters.""" - await self.client.send_command( - PrepReleasePlate(dest=await self._require("pipettor")) - ) + """Release plate / open gripper (PrepCmd.PrepReleasePlate, cmd=21). No parameters.""" + await self.client.send_command(PrepCmd.PrepReleasePlate(dest=await self._require("pipettor"))) async def pick_up_resource( self, @@ -1380,14 +1445,12 @@ async def pick_up_resource( if self.deck is None: raise RuntimeError("deck not set") if pickup.direction != GripDirection.FRONT: - raise NotImplementedError( - "PREP CORE gripper only supports GripDirection.FRONT" - ) + raise NotImplementedError("PREP CORE gripper only supports GripDirection.FRONT") resource = pickup.resource center = resource.get_location_wrt(self.deck, "c", "c", "t") + pickup.offset grip_height = center.z - pickup.pickup_distance_from_top # plate_top_center = literal top center of plate (x, y, z_top); grip_height is separate. - plate_top_center = XYZCoord( + plate_top_center = PrepCmd.XYZCoord( default_values=False, x_position=center.x, y_position=center.y, @@ -1395,7 +1458,7 @@ async def pick_up_resource( ) # Grip distance = how far the grippers close from open (travel). Open = labware_y + clearance_y, final = labware_y - squeeze_mm → close by clearance_y + squeeze_mm. grip_distance = clearance_y + squeeze_mm - plate_dims = PlateDimensions( + plate_dims = PrepCmd.PlateDimensions( default_values=False, length=resource.get_absolute_size_x(), width=resource.get_absolute_size_y(), @@ -1416,7 +1479,7 @@ async def pick_up_resource( tool_seek=loc.z + 10.0, ) await self.client.send_command( - PrepPickUpPlate( + PrepCmd.PrepPickUpPlate( dest=await self._require("pipettor"), plate_top_center=plate_top_center, plate=plate_dims, @@ -1436,14 +1499,14 @@ async def move_picked_up_resource(self, move: ResourceMove): - Coordinate(z=move.pickup_distance_from_top) + move.offset ) - plate_top_center = XYZCoord( + plate_top_center = PrepCmd.XYZCoord( default_values=False, x_position=center.x, y_position=center.y, z_position=center.z, ) await self.client.send_command( - PrepMovePlate( + PrepCmd.PrepMovePlate( dest=await self._require("pipettor"), plate_top_center=plate_top_center, acceleration_scale_x=1, @@ -1460,24 +1523,16 @@ async def drop_resource( if self.deck is None: raise RuntimeError("deck not set") resource = drop.resource - dest_center = ( - drop.destination - + resource.get_anchor("c", "c", "t") - + drop.offset - ) - place_z = ( - drop.destination.z - + resource.get_absolute_size_z() - - drop.pickup_distance_from_top - ) - plate_top_center = XYZCoord( + dest_center = drop.destination + resource.get_anchor("c", "c", "t") + drop.offset + place_z = drop.destination.z + resource.get_absolute_size_z() - drop.pickup_distance_from_top + plate_top_center = PrepCmd.XYZCoord( default_values=False, x_position=dest_center.x, y_position=dest_center.y, z_position=place_z, ) await self.client.send_command( - PrepDropPlate( + PrepCmd.PrepDropPlate( dest=await self._require("pipettor"), plate_top_center=plate_top_center, clearance_y=clearance_y, @@ -1507,16 +1562,16 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: async def park(self) -> None: """Park the instrument.""" - await self.client.send_command(PrepPark(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepPark(dest=await self._require("mlprep"))) async def spread(self) -> None: """Spread channels.""" - await self.client.send_command(PrepSpread(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepSpread(dest=await self._require("mlprep"))) async def method_begin(self, automatic_pause: bool = False) -> None: """Signal the start of a liquid-handling method.""" await self.client.send_command( - PrepMethodBegin( + PrepCmd.PrepMethodBegin( dest=await self._require("mlprep"), automatic_pause=automatic_pause, ) @@ -1524,39 +1579,37 @@ async def method_begin(self, automatic_pause: bool = False) -> None: async def method_end(self) -> None: """Signal the end of a liquid-handling method.""" - await self.client.send_command(PrepMethodEnd(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepMethodEnd(dest=await self._require("mlprep"))) async def method_abort(self) -> None: """Abort the current method.""" - await self.client.send_command(PrepMethodAbort(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepMethodAbort(dest=await self._require("mlprep"))) async def power_down_request(self) -> None: """Request power down (instrument will prepare for shutdown; use cancel_power_down to abort).""" - await self.client.send_command(PrepPowerDownRequest(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepPowerDownRequest(dest=await self._require("mlprep"))) async def confirm_power_down(self) -> None: """Confirm power down (completes shutdown; only call when safe to power off).""" - await self.client.send_command(PrepConfirmPowerDown(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepConfirmPowerDown(dest=await self._require("mlprep"))) async def cancel_power_down(self) -> None: """Cancel a pending power-down request.""" - await self.client.send_command(PrepCancelPowerDown(dest=await self._require("mlprep"))) + await self.client.send_command(PrepCmd.PrepCancelPowerDown(dest=await self._require("mlprep"))) async def get_deck_light(self) -> Tuple[int, int, int, int]: - """Get the current deck LED colour (white, red, green, blue).""" - result = await self.client.send_command( - PrepGetDeckLight(dest=await self._require("mlprep")) - ) - if result is None: - raise ValueError("No response from GetDeckLight.") - return (result.white, result.red, result.green, result.blue) + """Get the current deck LED colour (white, red, green, blue).""" + result = await self.client.send_command( + PrepCmd.PrepGetDeckLight(dest=await self._require("mlprep")) + ) + if result is None: + raise ValueError("No response from GetDeckLight.") + return (result.white, result.red, result.green, result.blue) - async def set_deck_light( - self, white: int, red: int, green: int, blue: int - ) -> None: + async def set_deck_light(self, white: int, red: int, green: int, blue: int) -> None: """Set the deck LED colour.""" await self.client.send_command( - PrepSetDeckLight( + PrepCmd.PrepSetDeckLight( dest=await self._require("mlprep"), white=white, red=red, @@ -1584,9 +1637,7 @@ async def disco_mode(self) -> None: # Pipettor convenience methods # --------------------------------------------------------------------------- - async def move_channels_to_safe_z( - self, channels: Optional[List[int]] = None - ) -> None: + async def move_channels_to_safe_z(self, channels: Optional[List[int]] = None) -> None: """Move the given channels' Z axes up to safe (traverse) height (cmd=28). Use after picking up a tool or before returning a tool to avoid collisions @@ -1607,7 +1658,7 @@ async def move_channels_to_safe_z( ) channel_enums = [_CHANNEL_INDEX[ch] for ch in channels] await self.client.send_command( - PrepMoveZUpToSafe( + PrepCmd.PrepMoveZUpToSafe( dest=await self._require("pipettor"), channels=channel_enums, ) @@ -1645,19 +1696,19 @@ async def move_to_position( if isinstance(z, list): assert len(z) == len(channels), "len(z) must equal len(use_channels)" - axis_parameters: List[ChannelYZMoveParameters] = [] + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] for i, ch in enumerate(channels): y_i = y if isinstance(y, (int, float)) else y[i] z_i = z if isinstance(z, (int, float)) else z[i] axis_parameters.append( - ChannelYZMoveParameters( + PrepCmd.ChannelYZMoveParameters( default_values=False, channel=_CHANNEL_INDEX[ch], y_position=y_i, z_position=z_i, ) ) - move_parameters = GantryMoveXYZParameters( + move_parameters = PrepCmd.GantryMoveXYZParameters( default_values=False, gantry_x_position=x, axis_parameters=axis_parameters, @@ -1665,14 +1716,14 @@ async def move_to_position( if via_lane: await self.client.send_command( - PrepMoveToPositionViaLane( + PrepCmd.PrepMoveToPositionViaLane( dest=await self._require("pipettor"), move_parameters=move_parameters, ) ) else: await self.client.send_command( - PrepMoveToPosition( + PrepCmd.PrepMoveToPosition( dest=await self._require("pipettor"), move_parameters=move_parameters, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py index 9bcba2a0b7a..7bb9f8aa754 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py @@ -15,39 +15,7 @@ _build_container_segments, _effective_radius, ) -from pylabrobot.liquid_handling.backends.hamilton.prep_commands import ( - ChannelIndex, - DeckBounds, - InstrumentConfig, - LldParameters, - MphDropTips, - MphPickupTips, - MonitoringMode, - PrepAspirateNoLldMonitoringV2, - PrepAspirateWithLldTadmV2, - PrepAspirateWithLldV2, - PrepAspirateTadmV2, - PrepDispenseNoLldV2, - PrepDispenseWithLldV2, - PrepDropPlate, - PrepDropTips, - PrepDropTool, - PrepMethodBegin, - PrepMethodEnd, - PrepMethodAbort, - PrepMovePlate, - PrepMoveToPosition, - PrepMoveToPositionViaLane, - PrepMoveZUpToSafe, - PrepPark, - PrepPickUpPlate, - PrepPickUpTips, - PrepPickUpTool, - PrepSetDeckLight, - PrepSpread, - SegmentDescriptor, - TipDropType, -) +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.liquid_classes.hamilton import get_star_liquid_class from pylabrobot.liquid_handling.standard import ( @@ -60,17 +28,22 @@ SingleChannelAspiration, SingleChannelDispense, ) -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb -from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize -from pylabrobot.resources.deck import Deck -from pylabrobot.resources.hamilton.hamilton_decks import PrepDeck -from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL_filter -from pylabrobot.resources.liquid import Liquid -from pylabrobot.resources.plate import Plate -from pylabrobot.resources.rotation import Rotation -from pylabrobot.resources.trash import Trash -from pylabrobot.resources.well import CrossSectionType, Well +from pylabrobot.resources import ( + Coordinate, + Cor_96_wellplate_360ul_Fb, + CrossSectionType, + Deck, + HamiltonTip, + Liquid, + Plate, + PrepDeck, + Rotation, + Trash, + Well, + hamilton_96_tiprack_300uL_filter, + TipPickupMethod, + TipSize, +) # ============================================================================= @@ -93,8 +66,8 @@ def _setup_backend(num_channels: int = 2, has_mph: bool = False) -> PrepBackend: backend._num_channels = num_channels backend._has_mph = has_mph backend._user_traverse_height = _TRAVERSE_HEIGHT - backend._config = InstrumentConfig( - deck_bounds=DeckBounds(0.0, 300.0, 0.0, 320.0, 0.0, 100.0), + backend._config = PrepCmd.InstrumentConfig( + deck_bounds=PrepCmd.DeckBounds(0.0, 300.0, 0.0, 320.0, 0.0, 100.0), has_enclosure=False, safe_speeds_enabled=False, deck_sites=(), @@ -134,11 +107,7 @@ def _setup_backend_with_deck( def _get_commands(mock_send, cmd_type): """Extract sent commands of a specific type from mock call list.""" - return [ - call.args[0] - for call in mock_send.call_args_list - if isinstance(call.args[0], cmd_type) - ] + return [call.args[0] for call in mock_send.call_args_list if isinstance(call.args[0], cmd_type)] # ============================================================================= @@ -181,6 +150,7 @@ def test_effective_radius_rectangular(self): def test_effective_radius_non_well_uses_size_x(self): # For non-Well objects the function falls back to size_x / 2 from pylabrobot.resources import Resource + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) self.assertAlmostEqual(_effective_radius(resource), 5.0) @@ -188,6 +158,7 @@ def test_effective_radius_non_well_uses_size_x(self): def test_build_container_segments_non_well(self): from pylabrobot.resources import Resource + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) segs = _build_container_segments(resource) self.assertEqual(segs, []) @@ -196,8 +167,8 @@ def test_build_container_segments_simple_circular(self): well = self._make_circular_well(diameter=6.0, height=10.0) segs = _build_container_segments(well) self.assertEqual(len(segs), 1) - expected_area = math.pi * (3.0 ** 2) - self.assertIsInstance(segs[0], SegmentDescriptor) + expected_area = math.pi * (3.0**2) + self.assertIsInstance(segs[0], PrepCmd.SegmentDescriptor) self.assertAlmostEqual(segs[0].area_top, expected_area, places=4) self.assertAlmostEqual(segs[0].area_bottom, expected_area, places=4) self.assertAlmostEqual(segs[0].height, 10.0, places=4) @@ -212,7 +183,7 @@ def test_build_container_segments_simple_rect(self): def test_build_container_segments_heights_sum_to_size_z(self): """Wells with compute_height_volume should produce 10 segments summing to size_z.""" - area = math.pi * 3.0 ** 2 + area = math.pi * 3.0**2 well = Well( name="w", size_x=6.0, @@ -230,13 +201,14 @@ def test_build_container_segments_heights_sum_to_size_z(self): # --- _absolute_z_from_well --- def test_absolute_z_from_well_geometry(self): - well = self._make_circular_well(diameter=6.0, height=10.0) + self._make_circular_well(diameter=6.0, height=10.0) deck = PrepDeck() deck[0] = Cor_96_wellplate_360ul_Fb("p") plate = deck[0].resource assert plate is not None and isinstance(plate, Plate) # Use a plate well with known absolute location from pylabrobot.liquid_handling.standard import SingleChannelAspiration + tip = hamilton_96_tiprack_300uL_filter("tr").get_item("A1").get_tip() op = SingleChannelAspiration( resource=plate.get_item("A1"), @@ -324,9 +296,13 @@ def test_resolve_traverse_height_user_set(self): def test_resolve_traverse_height_probed(self): backend = PrepBackend(host="localhost", port=2000) - backend._config = InstrumentConfig( - deck_bounds=None, has_enclosure=False, safe_speeds_enabled=False, - deck_sites=(), waste_sites=(), default_traverse_height=75.0, + backend._config = PrepCmd.InstrumentConfig( + deck_bounds=None, + has_enclosure=False, + safe_speeds_enabled=False, + deck_sites=(), + waste_sites=(), + default_traverse_height=75.0, ) self.assertAlmostEqual(backend._resolve_traverse_height(None), 75.0) @@ -348,13 +324,18 @@ def test_resolve_traverse_height_explicit_beats_user(self): def test_can_pick_up_tip_hamilton_tip(self): backend = _setup_backend() tip = HamiltonTip( - name="t", has_filter=False, total_tip_length=59.9, maximal_volume=300.0, - tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + name="t", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, ) self.assertTrue(backend.can_pick_up_tip(0, tip)) def test_can_pick_up_tip_non_hamilton(self): from pylabrobot.resources import Tip + backend = _setup_backend() tip = Tip( name="generic_tip", @@ -368,21 +349,30 @@ def test_can_pick_up_tip_non_hamilton(self): def test_can_pick_up_tip_xl_rejected(self): backend = _setup_backend() tip = HamiltonTip( - name="t", has_filter=False, total_tip_length=95.0, maximal_volume=5000.0, - tip_size=TipSize.XL, pickup_method=TipPickupMethod.OUT_OF_RACK, + name="t", + has_filter=False, + total_tip_length=95.0, + maximal_volume=5000.0, + tip_size=TipSize.XL, + pickup_method=TipPickupMethod.OUT_OF_RACK, ) self.assertFalse(backend.can_pick_up_tip(0, tip)) def test_can_pick_up_tip_channel_out_of_range(self): backend = _setup_backend(num_channels=2) tip = HamiltonTip( - name="t", has_filter=False, total_tip_length=59.9, maximal_volume=300.0, - tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + name="t", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, ) self.assertFalse(backend.can_pick_up_tip(2, tip)) def test_not_implemented_96_head_methods(self): import asyncio + backend = _setup_backend() with self.assertRaises(NotImplementedError): asyncio.run(backend.pick_up_tips96(None)) # type: ignore[arg-type] @@ -410,13 +400,13 @@ async def test_pick_up_tips_single_channel_ch0(self): [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], use_channels=[0], ) - cmds = _get_commands(self.mock_send, PrepPickUpTips) + cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips) self.assertEqual(len(cmds), 1) cmd = cmds[0] self.assertEqual(cmd.dest, _PIPETTOR_ADDR) self.assertEqual(len(cmd.tip_positions), 1) tp = cmd.tip_positions[0] - self.assertEqual(tp.channel, ChannelIndex.RearChannel) + self.assertEqual(tp.channel, PrepCmd.ChannelIndex.RearChannel) # Verify Z geometry loc = tip_spot.get_absolute_location("c", "c", "t") @@ -437,11 +427,11 @@ async def test_pick_up_tips_two_channels(self): ], use_channels=[0, 1], ) - cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] self.assertEqual(len(cmd.tip_positions), 2) channels = [tp.channel for tp in cmd.tip_positions] - self.assertIn(ChannelIndex.RearChannel, channels) - self.assertIn(ChannelIndex.FrontChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) async def test_pick_up_tips_custom_final_z(self): tip_spot = self.tip_rack.get_item("A1") @@ -451,7 +441,7 @@ async def test_pick_up_tips_custom_final_z(self): use_channels=[0], final_z=55.0, ) - cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] self.assertAlmostEqual(cmd.final_z, 55.0) async def test_pick_up_tips_default_final_z_from_traverse(self): @@ -461,7 +451,7 @@ async def test_pick_up_tips_default_final_z_from_traverse(self): [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], use_channels=[0], ) - cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] self.assertAlmostEqual(cmd.final_z, _TRAVERSE_HEIGHT) async def test_pick_up_tips_z_seek_offset(self): @@ -472,7 +462,7 @@ async def test_pick_up_tips_z_seek_offset(self): use_channels=[0], z_seek_offset=3.0, ) - cmd = _get_commands(self.mock_send, PrepPickUpTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] tp = cmd.tip_positions[0] loc = tip_spot.get_absolute_location("c", "c", "t") base_z = loc.z + tip.total_tip_length - tip.fitting_depth @@ -497,7 +487,7 @@ async def test_drop_tips_to_rack_uses_tip_geometry(self): [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], use_channels=[0], ) - cmd = _get_commands(self.mock_send, PrepDropTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] self.assertEqual(len(cmd.tip_positions), 1) dp = cmd.tip_positions[0] loc = tip_spot.get_absolute_location("c", "c", "t") @@ -513,7 +503,7 @@ async def test_drop_tips_to_waste_position(self): [Drop(resource=waste, offset=Coordinate.zero(), tip=tip)], use_channels=[0], ) - cmd = _get_commands(self.mock_send, PrepDropTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] dp = cmd.tip_positions[0] loc = waste.get_absolute_location("c", "c", "t") # Waste: same as tip spots — z_position so tip bottom lands at surface; z_seek for approach @@ -523,7 +513,7 @@ async def test_drop_tips_to_waste_position(self): self.assertAlmostEqual(dp.z_seek, expected_z_seek, places=3) # Default roll-off when all Trash; use Stall so pipette detects contact before release self.assertAlmostEqual(cmd.tip_roll_off_distance, 3.0) - self.assertEqual(dp.drop_type, TipDropType.Stall) + self.assertEqual(dp.drop_type, PrepCmd.TipDropType.Stall) async def test_drop_tips_stall_type(self): tip_spot = self.tip_rack.get_item("A1") @@ -531,10 +521,10 @@ async def test_drop_tips_stall_type(self): await self.backend.drop_tips( [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], use_channels=[0], - drop_type=TipDropType.Stall, + drop_type=PrepCmd.TipDropType.Stall, ) - cmd = _get_commands(self.mock_send, PrepDropTips)[0] - self.assertEqual(cmd.tip_positions[0].drop_type, TipDropType.Stall) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + self.assertEqual(cmd.tip_positions[0].drop_type, PrepCmd.TipDropType.Stall) async def test_drop_tips_roll_off_distance(self): tip_spot = self.tip_rack.get_item("A1") @@ -544,7 +534,7 @@ async def test_drop_tips_roll_off_distance(self): use_channels=[0], tip_roll_off_distance=2.5, ) - cmd = _get_commands(self.mock_send, PrepDropTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] self.assertAlmostEqual(cmd.tip_roll_off_distance, 2.5) async def test_drop_tips_all_trash_resolves_to_deck_waste_and_default_roll(self): @@ -559,7 +549,7 @@ async def test_drop_tips_all_trash_resolves_to_deck_waste_and_default_roll(self) ], use_channels=[0, 1], ) - cmd = _get_commands(self.mock_send, PrepDropTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] self.assertEqual(len(cmd.tip_positions), 2) waste_rear = self.deck.get_resource("waste_rear") waste_front = self.deck.get_resource("waste_front") @@ -583,7 +573,7 @@ async def test_drop_tips_all_trash_deck_missing_waste_raises(self): trash = Trash(name="trash", size_x=0.0, size_y=0.0, size_z=0.0) deck.assign_child_resource(trash, location=Coordinate(287.0, 0.0, 0.0)) backend._deck = deck - backend.client.send_command = unittest.mock.AsyncMock(return_value=None) + backend.client.send_command = unittest.mock.AsyncMock(return_value=None) # type: ignore[method-assign] tip = hamilton_96_tiprack_300uL_filter("_tmp").get_item("A1").get_tip() with self.assertRaises(ValueError) as ctx: await backend.drop_tips( @@ -624,8 +614,9 @@ async def asyncSetUp(self): self.backend.client.send_command = self.mock_send self.tip = self.tip_rack.get_item("A1").get_tip() - def _make_asp(self, well_name="A1", volume=100.0, flow_rate=None, - liquid_height=5.0, blow_out_air_volume=0.0): + def _make_asp( + self, well_name="A1", volume=100.0, flow_rate=None, liquid_height=5.0, blow_out_air_volume=0.0 + ): return SingleChannelAspiration( resource=self.plate.get_item(well_name), offset=Coordinate.zero(), @@ -641,35 +632,44 @@ def _make_asp(self, well_name="A1", volume=100.0, flow_rate=None, async def test_aspirate_default_sends_nolld_monitoring(self): await self.backend.aspirate([self._make_asp()], use_channels=[0]) - self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)), 1) async def test_aspirate_tadm_mode(self): await self.backend.aspirate( - [self._make_asp()], use_channels=[0], - monitoring_mode=MonitoringMode.TADM, + [self._make_asp()], + use_channels=[0], + monitoring_mode=PrepCmd.MonitoringMode.TADM, ) - self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateTadmV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateTadmV2)), 1) async def test_aspirate_lld_mode(self): await self.backend.aspirate([self._make_asp()], use_channels=[0], use_lld=True) - self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateWithLldV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2)), 1) async def test_aspirate_lld_tadm_mode(self): await self.backend.aspirate( - [self._make_asp()], use_channels=[0], - use_lld=True, monitoring_mode=MonitoringMode.TADM, + [self._make_asp()], + use_channels=[0], + use_lld=True, + monitoring_mode=PrepCmd.MonitoringMode.TADM, ) - self.assertEqual(len(_get_commands(self.mock_send, PrepAspirateWithLldTadmV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldTadmV2)), 1) async def test_aspirate_implicit_lld_via_lld_param(self): """Passing lld= activates LLD path without use_lld=True.""" - custom_lld = LldParameters( - default_values=False, z_seek=90.0, z_seek_speed=5.0, z_submerge=2.0, z_out_of_liquid=1.0, + custom_lld = PrepCmd.LldParameters( + default_values=False, + z_seek=90.0, + z_seek_speed=5.0, + z_submerge=2.0, + z_out_of_liquid=1.0, ) await self.backend.aspirate( - [self._make_asp()], use_channels=[0], lld=custom_lld, + [self._make_asp()], + use_channels=[0], + lld=custom_lld, ) - cmds = _get_commands(self.mock_send, PrepAspirateWithLldV2) + cmds = _get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2) self.assertEqual(len(cmds), 1) # Verify the provided LLD parameters are used (not auto-derived) lld = cmds[0].aspirate_parameters[0].lld @@ -677,11 +677,11 @@ async def test_aspirate_implicit_lld_via_lld_param(self): async def test_aspirate_lld_auto_seek_z(self): """Auto-derived LLD z_seek equals the top-of-well Z.""" - well = self.plate.get_item("A1") + self.plate.get_item("A1") op = self._make_asp() _, _, top_of_well_z, _ = _absolute_z_from_well(op) await self.backend.aspirate([op], use_channels=[0], use_lld=True) - cmd = _get_commands(self.mock_send, PrepAspirateWithLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2)[0] lld = cmd.aspirate_parameters[0].lld self.assertAlmostEqual(lld.z_seek, top_of_well_z, places=3) @@ -689,23 +689,25 @@ async def test_aspirate_lld_auto_seek_z(self): async def test_aspirate_channel_0_is_rear(self): await self.backend.aspirate([self._make_asp()], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] - self.assertEqual(cmd.aspirate_parameters[0].channel, ChannelIndex.RearChannel) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, PrepCmd.ChannelIndex.RearChannel) async def test_aspirate_channel_1_is_front(self): await self.backend.aspirate([self._make_asp()], use_channels=[1]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] - self.assertEqual(cmd.aspirate_parameters[0].channel, ChannelIndex.FrontChannel) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, PrepCmd.ChannelIndex.FrontChannel) async def test_aspirate_two_channels(self): - ops = [self._make_asp("A1", volume=100.0, flow_rate=50.0), - self._make_asp("B1", volume=150.0, flow_rate=75.0)] + ops = [ + self._make_asp("A1", volume=100.0, flow_rate=50.0), + self._make_asp("B1", volume=150.0, flow_rate=75.0), + ] await self.backend.aspirate(ops, use_channels=[0, 1]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] self.assertEqual(len(cmd.aspirate_parameters), 2) channels = {p.channel for p in cmd.aspirate_parameters} - self.assertIn(ChannelIndex.RearChannel, channels) - self.assertIn(ChannelIndex.FrontChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) # --- Volume and flow rate --- @@ -713,15 +715,20 @@ async def test_aspirate_volume_corrected_by_hlc(self): """HLC-corrected volume is sent, not raw op.volume.""" op = self._make_asp(volume=100.0) hlc = get_star_liquid_class( - tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, - has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, ) if hlc is not None: expected_vol = hlc.compute_corrected_volume(100.0) else: expected_vol = 100.0 await self.backend.aspirate([op], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] actual_vol = cmd.aspirate_parameters[0].common.liquid_volume self.assertAlmostEqual(actual_vol, expected_vol, places=2) @@ -729,58 +736,71 @@ async def test_aspirate_disable_volume_correction(self): """Raw volume used when disable_volume_correction=True.""" raw_volume = 100.0 await self.backend.aspirate( - [self._make_asp(volume=raw_volume)], use_channels=[0], + [self._make_asp(volume=raw_volume)], + use_channels=[0], disable_volume_correction=[True], ) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] actual_vol = cmd.aspirate_parameters[0].common.liquid_volume self.assertAlmostEqual(actual_vol, raw_volume, places=2) async def test_aspirate_explicit_flow_rate(self): await self.backend.aspirate([self._make_asp(flow_rate=60.0)], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, 60.0) async def test_aspirate_flow_rate_from_hlc_default(self): """flow_rate=None -> uses HLC aspiration_flow_rate.""" hlc = get_star_liquid_class( - tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, - has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, ) await self.backend.aspirate([self._make_asp(flow_rate=None)], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] expected = hlc.aspiration_flow_rate if hlc is not None else 100.0 self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, expected, places=2) async def test_aspirate_explicit_settling_time_override(self): await self.backend.aspirate( - [self._make_asp()], use_channels=[0], + [self._make_asp()], + use_channels=[0], settling_time=[2.0], ) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, 2.0) async def test_aspirate_hlc_settling_time_default(self): """Settling time from HLC when not explicitly passed.""" hlc = get_star_liquid_class( - tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, - has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, ) await self.backend.aspirate([self._make_asp()], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] expected = hlc.aspiration_settling_time if hlc is not None else 1.0 self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, expected, places=3) async def test_aspirate_auto_container_geometry(self): """auto_container_geometry=True produces non-empty container_description.""" await self.backend.aspirate( - [self._make_asp()], use_channels=[0], + [self._make_asp()], + use_channels=[0], auto_container_geometry=True, ) - cmd = _get_commands(self.mock_send, PrepAspirateNoLldMonitoringV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] segs = cmd.aspirate_parameters[0].container_description self.assertGreater(len(segs), 0) - self.assertIsInstance(segs[0], SegmentDescriptor) + self.assertIsInstance(segs[0], PrepCmd.SegmentDescriptor) # ============================================================================= @@ -797,8 +817,9 @@ async def asyncSetUp(self): self.backend.client.send_command = self.mock_send self.tip = self.tip_rack.get_item("A1").get_tip() - def _make_disp(self, well_name="A1", volume=100.0, flow_rate=None, - liquid_height=5.0, blow_out_air_volume=0.0): + def _make_disp( + self, well_name="A1", volume=100.0, flow_rate=None, liquid_height=5.0, blow_out_air_volume=0.0 + ): return SingleChannelDispense( resource=self.plate.get_item(well_name), offset=Coordinate.zero(), @@ -812,50 +833,57 @@ def _make_disp(self, well_name="A1", volume=100.0, flow_rate=None, async def test_dispense_default_sends_nolld(self): await self.backend.dispense([self._make_disp()], use_channels=[0]) - self.assertEqual(len(_get_commands(self.mock_send, PrepDispenseNoLldV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)), 1) async def test_dispense_lld_mode(self): await self.backend.dispense([self._make_disp()], use_channels=[0], use_lld=True) - self.assertEqual(len(_get_commands(self.mock_send, PrepDispenseWithLldV2)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDispenseWithLldV2)), 1) async def test_dispense_volume_corrected(self): hlc = get_star_liquid_class( - tip_volume=self.tip.maximal_volume, is_core=False, is_tip=True, - has_filter=self.tip.has_filter, liquid=Liquid.WATER, jet=False, blow_out=False, + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, ) raw = 100.0 expected = hlc.compute_corrected_volume(raw) if hlc else raw await self.backend.dispense([self._make_disp(volume=raw)], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] self.assertAlmostEqual(cmd.dispense_parameters[0].common.liquid_volume, expected, places=2) async def test_dispense_explicit_stop_back_volume(self): await self.backend.dispense( - [self._make_disp()], use_channels=[0], + [self._make_disp()], + use_channels=[0], stop_back_volume=[3.0], ) - cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.stop_back_volume, 3.0) async def test_dispense_explicit_cutoff_speed(self): await self.backend.dispense( - [self._make_disp()], use_channels=[0], + [self._make_disp()], + use_channels=[0], cutoff_speed=[75.0], ) - cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.cutoff_speed, 75.0) async def test_dispense_two_channels(self): ops = [self._make_disp("A1", volume=100.0), self._make_disp("B1", volume=200.0)] await self.backend.dispense(ops, use_channels=[0, 1]) - cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] self.assertEqual(len(cmd.dispense_parameters), 2) async def test_dispense_z_minimum_from_well_bottom(self): op = self._make_disp() loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") await self.backend.dispense([op], use_channels=[0]) - cmd = _get_commands(self.mock_send, PrepDispenseNoLldV2)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] self.assertAlmostEqual(cmd.dispense_parameters[0].common.z_minimum, loc.z, places=3) @@ -875,26 +903,26 @@ async def asyncSetUp(self): async def test_mph_pickup_sends_mph_command(self): tip_spot = self.tip_rack.get_item("A1") await self.backend.pick_up_tips_mph(tip_spot) - self.assertEqual(len(_get_commands(self.mock_send, MphPickupTips)), 1) - # Must not send single-channel PrepPickUpTips - self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpTips)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.MphPickupTips)), 1) + # Must not send single-channel PrepCmd.PrepPickUpTips + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpTips)), 0) async def test_mph_pickup_default_tip_mask(self): tip_spot = self.tip_rack.get_item("A1") await self.backend.pick_up_tips_mph(tip_spot) - cmd = _get_commands(self.mock_send, MphPickupTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.MphPickupTips)[0] self.assertEqual(cmd.tip_mask, 0xFF) async def test_mph_pickup_custom_tip_mask(self): tip_spot = self.tip_rack.get_item("A1") await self.backend.pick_up_tips_mph(tip_spot, tip_mask=0x0F) - cmd = _get_commands(self.mock_send, MphPickupTips)[0] + cmd = _get_commands(self.mock_send, PrepCmd.MphPickupTips)[0] self.assertEqual(cmd.tip_mask, 0x0F) async def test_mph_drop_sends_mph_command(self): tip_spot = self.tip_rack.get_item("A1") await self.backend.drop_tips_mph(tip_spot) - self.assertEqual(len(_get_commands(self.mock_send, MphDropTips)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.MphDropTips)), 1) async def test_mph_pickup_raises_when_no_mph(self): backend = _setup_backend(has_mph=False) @@ -951,11 +979,11 @@ def _make_move(self, resource, location: Coordinate, pickup_distance_from_top=5. ) async def test_auto_picks_up_tool_before_plate(self): - """When _gripper_tool_on=False, PrepPickUpTool is sent before PrepPickUpPlate.""" + """When _gripper_tool_on=False, PrepCmd.PrepPickUpTool is sent before PrepCmd.PrepPickUpPlate.""" self.assertFalse(self.backend._gripper_tool_on) await self.backend.pick_up_resource(self._make_pickup(self.plate)) - tool_cmds = _get_commands(self.mock_send, PrepPickUpTool) - plate_cmds = _get_commands(self.mock_send, PrepPickUpPlate) + tool_cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpTool) + plate_cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate) self.assertEqual(len(tool_cmds), 1) self.assertEqual(len(plate_cmds), 1) # Tool must be picked up before plate @@ -966,12 +994,12 @@ async def test_auto_picks_up_tool_before_plate(self): async def test_skip_tool_pickup_when_already_holding(self): self.backend._gripper_tool_on = True await self.backend.pick_up_resource(self._make_pickup(self.plate)) - self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpTool)), 0) - self.assertEqual(len(_get_commands(self.mock_send, PrepPickUpPlate)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)), 1) async def test_plate_dimensions_from_resource(self): await self.backend.pick_up_resource(self._make_pickup(self.plate)) - cmd = _get_commands(self.mock_send, PrepPickUpPlate)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)[0] self.assertAlmostEqual(cmd.plate.length, self.plate.get_absolute_size_x(), places=3) self.assertAlmostEqual(cmd.plate.width, self.plate.get_absolute_size_y(), places=3) self.assertAlmostEqual(cmd.plate.height, self.plate.get_absolute_size_z(), places=3) @@ -984,7 +1012,7 @@ async def test_grip_distance_is_clearance_plus_squeeze(self): clearance_y=clearance_y, squeeze_mm=squeeze_mm, ) - cmd = _get_commands(self.mock_send, PrepPickUpPlate)[0] + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)[0] self.assertAlmostEqual(cmd.grip_distance, clearance_y + squeeze_mm) async def test_grip_direction_not_front_raises(self): @@ -1002,8 +1030,8 @@ async def test_drop_resource_with_return_gripper(self): plate_loc = self.plate.get_absolute_location() drop = self._make_drop(self.plate, destination=plate_loc) await self.backend.drop_resource(drop, return_gripper=True) - drop_plate_cmds = _get_commands(self.mock_send, PrepDropPlate) - drop_tool_cmds = _get_commands(self.mock_send, PrepDropTool) + drop_plate_cmds = _get_commands(self.mock_send, PrepCmd.PrepDropPlate) + drop_tool_cmds = _get_commands(self.mock_send, PrepCmd.PrepDropTool) self.assertEqual(len(drop_plate_cmds), 1) self.assertEqual(len(drop_tool_cmds), 1) self.assertFalse(self.backend._gripper_tool_on) @@ -1013,15 +1041,15 @@ async def test_drop_resource_without_return_gripper(self): plate_loc = self.plate.get_absolute_location() drop = self._make_drop(self.plate, destination=plate_loc) await self.backend.drop_resource(drop, return_gripper=False) - self.assertEqual(len(_get_commands(self.mock_send, PrepDropTool)), 0) - self.assertEqual(len(_get_commands(self.mock_send, PrepDropPlate)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDropTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDropPlate)), 1) async def test_move_picked_up_resource(self): self.backend._gripper_tool_on = True dest = Coordinate(100.0, 50.0, 10.0) move = self._make_move(self.plate, location=dest) await self.backend.move_picked_up_resource(move) - cmds = _get_commands(self.mock_send, PrepMovePlate) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMovePlate) self.assertEqual(len(cmds), 1) @@ -1040,62 +1068,64 @@ async def asyncSetUp(self): async def test_park(self): await self.backend.park() - cmds = _get_commands(self.mock_send, PrepPark) + cmds = _get_commands(self.mock_send, PrepCmd.PrepPark) self.assertEqual(len(cmds), 1) self.assertEqual(cmds[0].dest, _MLPREP_ADDR) async def test_spread(self): await self.backend.spread() - cmds = _get_commands(self.mock_send, PrepSpread) + cmds = _get_commands(self.mock_send, PrepCmd.PrepSpread) self.assertEqual(len(cmds), 1) self.assertEqual(cmds[0].dest, _MLPREP_ADDR) async def test_method_begin_automatic_pause(self): await self.backend.method_begin(automatic_pause=True) - cmds = _get_commands(self.mock_send, PrepMethodBegin) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMethodBegin) self.assertEqual(len(cmds), 1) self.assertTrue(cmds[0].automatic_pause) async def test_method_begin_no_automatic_pause(self): await self.backend.method_begin(automatic_pause=False) - cmds = _get_commands(self.mock_send, PrepMethodBegin) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMethodBegin) self.assertFalse(cmds[0].automatic_pause) async def test_method_end(self): await self.backend.method_end() - self.assertEqual(len(_get_commands(self.mock_send, PrepMethodEnd)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMethodEnd)), 1) async def test_method_abort(self): await self.backend.method_abort() - self.assertEqual(len(_get_commands(self.mock_send, PrepMethodAbort)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMethodAbort)), 1) async def test_move_to_position(self): await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0]) - cmds = _get_commands(self.mock_send, PrepMoveToPosition) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMoveToPosition) self.assertEqual(len(cmds), 1) cmd = cmds[0] self.assertAlmostEqual(cmd.move_parameters.gantry_x_position, 100.0) self.assertEqual(len(cmd.move_parameters.axis_parameters), 1) - self.assertEqual(cmd.move_parameters.axis_parameters[0].channel, ChannelIndex.RearChannel) + self.assertEqual( + cmd.move_parameters.axis_parameters[0].channel, PrepCmd.ChannelIndex.RearChannel + ) self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].y_position, 50.0) self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].z_position, 20.0) async def test_move_to_position_via_lane(self): await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0], via_lane=True) - self.assertEqual(len(_get_commands(self.mock_send, PrepMoveToPositionViaLane)), 1) - self.assertEqual(len(_get_commands(self.mock_send, PrepMoveToPosition)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMoveToPositionViaLane)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMoveToPosition)), 0) async def test_move_channels_to_safe_z_all(self): await self.backend.move_channels_to_safe_z() - cmds = _get_commands(self.mock_send, PrepMoveZUpToSafe) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMoveZUpToSafe) self.assertEqual(len(cmds), 1) channels = cmds[0].channels - self.assertIn(ChannelIndex.RearChannel, channels) - self.assertIn(ChannelIndex.FrontChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) async def test_set_deck_light(self): await self.backend.set_deck_light(white=100, red=50, green=25, blue=200) - cmds = _get_commands(self.mock_send, PrepSetDeckLight) + cmds = _get_commands(self.mock_send, PrepCmd.PrepSetDeckLight) self.assertEqual(len(cmds), 1) cmd = cmds[0] self.assertEqual(cmd.white, 100) @@ -1105,9 +1135,6 @@ async def test_set_deck_light(self): self.assertEqual(cmd.dest, _MLPREP_ADDR) async def test_not_implemented_96_ops(self): - from pylabrobot.liquid_handling.standard import ( - DropTipRack, MultiHeadAspirationPlate, MultiHeadDispensePlate, PickupTipRack, - ) with self.assertRaises(NotImplementedError): await self.backend.pick_up_tips96(None) # type: ignore[arg-type] with self.assertRaises(NotImplementedError): diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py index 86faccfa5ad..3d35e7651c8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -35,7 +35,6 @@ U8Array, Enum as WEnum, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import InterfaceSpec # ============================================================================= @@ -73,7 +72,7 @@ class MonitoringMode(IntEnum): """Selects aspirate monitoring vs TADM for pipetting commands.""" MONITORING = 0 # AspirateMonitoringParameters (default, matches v1 behavior) - TADM = 1 # TadmParameters + TADM = 1 # TadmParameters # ============================================================================= @@ -126,7 +125,9 @@ class InstrumentConfig: safe_speeds_enabled: bool deck_sites: Tuple[DeckSiteInfo, ...] waste_sites: Tuple[WasteSiteInfo, ...] - default_traverse_height: Optional[float] = None # None if probe failed; user can set via set_default_traverse_height + default_traverse_height: Optional[float] = ( + None # None if probe failed; user can set via set_default_traverse_height + ) num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels @@ -350,7 +351,9 @@ class LldParameters: @classmethod def default(cls) -> LldParameters: - return cls(default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0) + return cls( + default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0 + ) @dataclass @@ -363,7 +366,9 @@ class CLldParameters: @classmethod def default(cls) -> CLldParameters: - return cls(default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0) + return cls( + default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0 + ) @dataclass @@ -376,7 +381,13 @@ class PLldParameters: @classmethod def default(cls) -> PLldParameters: - return cls(default_values=True, sensitivity=1, dispenser_seek_speed=0.0, lld_height_difference=0.0, detect_mode=0) + return cls( + default_values=True, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) @dataclass @@ -1727,5 +1738,3 @@ class PrepGetPresentChannels(_PrepStatusQuery): @dataclass(frozen=True) class Response: channels: EnumArray # list of ints: map to ChannelIndex for present channels - - diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py deleted file mode 100644 index 04e1d624795..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_tests.py +++ /dev/null @@ -1,267 +0,0 @@ -import unittest - -from pylabrobot.liquid_handling.backends.hamilton.prep import ( - ParameterTypes, - Prep, - encode_data_fragment, -) -from pylabrobot.liquid_handling.liquid_handler import LiquidHandler -from pylabrobot.resources.celltreat.plates import CellTreat_96_wellplate_350ul_Ub -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.hamilton.hamilton_decks import PrepDeck -from pylabrobot.resources.hamilton.tip_racks import STF - - -class PrepTransportLayerTests(unittest.TestCase): - ip_packet_data = bytes.fromhex( - "2000063000000200040001000100010004BF020002101C0000000000010001000000" - ) - harp_packet_data = bytes.fromhex("0200040001000100010004BF020002101C0000000000010001000000") - hoi_packet_data = bytes.fromhex("010001000000") - - def test_decode_ip_packet(self): - ip_packet = Prep.IpPacket.decode(self.ip_packet_data) - assert ip_packet.size == 32 - assert ip_packet.protocol == 6 - assert ip_packet.version == (3, 0) - assert ip_packet.options_length == 0 - assert ip_packet.options is None - assert ip_packet.payload == self.harp_packet_data - - def test_encode_ip_packet(self): - ip_packet = Prep.IpPacket( - protocol=Prep.IpPacket.TransportableProtocol.Harp2, - version=(3, 0), - options=None, - payload=self.harp_packet_data, - ) - data = ip_packet.encode() - assert data == self.ip_packet_data - - def test_decode_harp_packet(self): - harp_packet = Prep.HarpPacket.decode(self.harp_packet_data) - assert harp_packet.source == Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0001)) - assert harp_packet.destination == Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBF04)) - assert harp_packet.sequence_number == 2 - assert harp_packet.reserved_1 == 0 - assert harp_packet.protocol == 2 - assert harp_packet.action == Prep.HarpPacket.Action(0x10) - assert harp_packet.length == 28 - assert harp_packet.options_length == 0 - assert harp_packet.options == [] - assert harp_packet.version == 0 - assert harp_packet.reserved_2 == 0 - assert harp_packet.payload == self.hoi_packet_data - - def test_encode_harp_packet(self): - harp_packet = Prep.HarpPacket( - source=Prep.HarpPacket.HarpAddress((0x0002, 0x0004, 0x0001)), - destination=Prep.HarpPacket.HarpAddress((0x0001, 0x0001, 0xBF04)), - sequence_number=2, - reserved_1=0, - protocol=Prep.HarpPacket.HarpTransportableProtocol.Hoi2, - action=Prep.HarpPacket.Action(0x10), - options=[], - version=0, - reserved_2=0, - payload=self.hoi_packet_data, - ) - data = harp_packet.encode() - assert data == self.harp_packet_data - - def test_decode_hoi_packet(self): - hoi_packet = Prep.HoiPacket2.decode(self.hoi_packet_data) - assert hoi_packet.interface_id == 1 - assert hoi_packet.action == 0 - assert hoi_packet.action_id == 1 - assert hoi_packet.version == 0 - assert hoi_packet.number_of_fragments == 0 - - def test_encode_hoi_packet(self): - hoi_packet = Prep.HoiPacket2( - interface_id=1, action=0, action_id=1, version=0, data_fragments=[] - ) - data = hoi_packet.encode() - assert data == self.hoi_packet_data - - def test_encode_data_fragment(self): - assert encode_data_fragment(152.600, ParameterTypes.Real32Bit) == bytes.fromhex( - "280004009A991843" - ) - assert encode_data_fragment(False, ParameterTypes.Bool) == bytes.fromhex("170102000000") - assert encode_data_fragment(True, ParameterTypes.Bool) == bytes.fromhex("170102000100") - - -class PrepFirmwareInterfaceTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.prep = Prep() - self.prep.socket = unittest.mock.MagicMock() - self.deck = PrepDeck() - self.lh = LiquidHandler(backend=self.prep, deck=self.deck) - - self.tip_rack = STF(name="tr") - self.deck.assign_child_resource( - self.tip_rack, location=Coordinate(x=140.9, y=98.53, z=49.57) - ) # spot 7 - self.plate = CellTreat_96_wellplate_350ul_Ub(name="plate") - self.deck.assign_child_resource(self.plate, location=Coordinate(x=1.55, y=76.58, z=0)) # spot 3 - return await super().asyncSetUp() - - async def test_setup(self): - data = bytes.fromhex( - "440006300000020004000400010001000015070002134000000000000103010000021701020000001e001a001701020001002800040000808f4328000400000040401f000000" - ) - self.prep._id = 0x6 - self.prep.socket.recv.return_value = bytes.fromhex( - "200006300000010001000015020004000400010002041c0000000000010401000000" - ) - - await self.lh.setup() - self.prep.socket.send.assert_called_with(data) - - async def test_park(self): - data = bytes.fromhex("200006300000020004000400010001000015150002131C0000000000010303000000") - self.prep._id = 0x14 - self.prep.socket.recv.return_value = bytes.fromhex( - "200006300000010001000015020004000400090002041c0000000000010403000000" - ) - await self.prep.park() - self.prep.socket.send.assert_called_with(data) - - async def test_z_travel_configuration(self): - data = bytes.fromhex( - "28000630000002000400050001000100f0be0a00021324000000000001030d0000012000040003000000" - ) - self.prep._id = 0x9 - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000001000100f0be0200040005000a0002041c000000000001040d000000" - ) - await self.prep.z_travel_configuration(unknown=3) - self.prep.socket.send.assert_called_with(data) - - async def test_pick_up_tips(self): - await self.test_setup() - - data = bytes.fromhex( - "e2000630000002000700060000e00100001008000213de00000000000103090000071f0064001e002e001701020000002000040002000000280004009a991843280004007b5419432800040048e16b4228000400a4f08d421e002e001701020000002000040001000000280004009a991843280004007b5410432800040048e16b4228000400a4f08d422800040071bdf74228000400000070411e003000170102000000280004000000b443280004009a994f42200004000200000017010200010017010200000017010200000017010200000028000400000000002800040000007a43" - ) - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e001000010020007000600100002041c000000000001040c000000" - ) - - self.prep._id = 0x07 - await self.lh.pick_up_tips(self.tip_rack["C1", "D1"]) - self.prep.socket.send.assert_called_with(data) - - async def test_aspirate(self): - await self.test_pick_up_tips() - data = bytes.fromhex( - "60010630000002000700060000e0010000100b0002135c01000000000103010000011f003c011e00380117010200000020000400020000001e0026001701020000002800040066667c41280004005c6f1643280004000000000028000400000000001e00640017010200000017010200010028000400c3f5a0c028000400a4f0c1422800040000000040280004000000c842280004000000c84228000400000000002800040033334b4028000400000000002800040000000000280004000000803f06000400000000001e002c0017010200000028000400a4f0bd4228000400a4f0c142170102000000280004000000004028000400000000001e002400170102000100280004000000000028000400000000000401020000002800040000007a431e00140017010200010017010200010028000400000090401e002400170102000100170102000000170102000000050002001e00050002001e00050002001400" - ) - self.prep._id = 0x0A - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e0010000100200070006000b0002041c0000000000010401000000" - ) - await self.lh.aspirate(self.plate["A1"], vols=[100]) - self.prep.socket.send.assert_called_with(data) - - async def test_dispense(self): - await self.test_aspirate() - data = bytes.fromhex( - "50010630000002000700060000e0010000100d0002134c01000000000103050000011f002c011e00280117010200000020000400020000001e0026001701020000002800040066667c41280004005c6f16432800040000000000280004000000c8421e00640017010200000017010200010028000400c3f5a0c028000400a4f0c1422800040000000040280004000000c842280004000000c84228000400000000002800040033334b4028000400000000002800040000000000280004000000000006000400000000001e002c0017010200000028000400a4f0bd4228000400f628c642170102000000280004000000004028000400000000001e002400170102000100280004000000000028000400000000000401020000002800040000007a431e00140017010200010017010200010028000400000090401e0014001701020001000500020000002000040001000000" - ) - self.prep._id = 0x0C - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e0010000100200070006000d0002041c0000000000010405000000" - ) - await self.lh.dispense(self.plate["A1"], vols=[100]) - self.prep.socket.send.assert_called_with(data) - - async def test_drop_tips(self): - await self.test_pick_up_tips() - data = bytes.fromhex( - "b0000630000002000700060000e00100001004000213ac000000000001030c0000041f0074001e0036001701020000002000040002000000280004009a991843280004007b5419432800040048e16b4228000400a4f08d4220000400000000001e0036001701020000002000040001000000280004009a991843280004007b5410432800040048e16b4228000400a4f08d4220000400000000002800040071bdf74228000400000020412800040000000000" - ) - self.prep._id = 0x03 - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e001000010020007000600040002041c000000000001040c000000" - ) - await self.lh.return_tips() - self.prep.socket.send.assert_called_with(data) - - async def test_move_z_up_to_safe(self): - data = bytes.fromhex( - "2c000630000002000700060000e0010000100500021328000000000001031c000001230008000100000002000000" - ) - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e001000010020007000600050002041c000000000001041c000000" - ) - self.prep._id = 0x04 - await self.prep.move_z_up_to_safe( - channels=[Prep.ChannelIndex.FrontChannel, Prep.ChannelIndex.RearChannel] - ) - self.prep.socket.send.assert_called_with(data) - - async def test_move_to_position(self): - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e001000010020007000600060002041c000000000001041a000000" - ) - self.prep._id = 0x05 - await self.prep.move_to_position( - move_parameters=Prep.GantryMoveXYZParameters( - default_values=False, - gantry_x_position=100, - axis_parameters=[ - Prep.ChannelYZMoveParameters( - default_values=True, - channel=Prep.ChannelIndex.RearChannel, - y_position=185.2, - z_position=100, - ), - Prep.ChannelYZMoveParameters( - default_values=False, - channel=Prep.ChannelIndex.FrontChannel, - y_position=0, - z_position=100, - ), - ], - ) - ) - data = bytes.fromhex( - "7a000630000002000700060000e0010000100600021376000000000001031a0000011e005600170102000000280004000000c8421f0044001e001e0017010200010020000400020000002800040033333943280004000000c8421e001e0017010200000020000400010000002800040000000000280004000000c842" - ) - self.prep.socket.send.assert_called_with(data) - - async def test_move_to_position_via_lane(self): - self.prep.socket.recv.return_value = bytes.fromhex( - "20000630000000e001000010020007000600070002041c000000000001041b000000" - ) - self.prep._id = 0x06 - await self.prep.move_to_position_via_lane( - move_parameters=Prep.GantryMoveXYZParameters( - default_values=False, - gantry_x_position=152.6, - axis_parameters=[ - Prep.ChannelYZMoveParameters( - default_values=True, - channel=Prep.ChannelIndex.RearChannel, - y_position=153.33, - z_position=70.97, - ), - Prep.ChannelYZMoveParameters( - default_values=False, - channel=Prep.ChannelIndex.FrontChannel, - y_position=144.33, - z_position=70.97, - ), - ], - ) - ) - data = bytes.fromhex( - "7a000630000002000700060000e0010000100700021376000000000001031b0000011e005600170102000000280004009a9918431f0044001e001e001701020001002000040002000000280004007b54194328000400a4f08d421e001e001701020000002000040001000000280004007b54104328000400a4f08d42" - ) - self.prep.socket.send.assert_called_with(data) - - async def test_move_channel(self): - # await self.lh.move_channel_x() - pass diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index f0f3ca4ce17..d2809d28a7d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -149,7 +149,28 @@ def resolve_type_id(type_id: int) -> str: # 78 = enum (Argument); 60, 64 = struct (ReturnValue) — see _INTROSPECTION_TYPE_NAMES comments _ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 78, 82, 102} _RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 63, 68, 76} -_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 60, 64, 69, 81, 85, 104, 105} +_RETURN_VALUE_TYPE_IDS = { + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 36, + 44, + 48, + 52, + 56, + 60, + 64, + 69, + 81, + 85, + 104, + 105, +} # Complex type sentinels: byte values that begin a 3-byte triple [type_id, source_id, ref_id]. # The two contexts (method parameterTypes vs struct structureElementTypes) use different sentinels. @@ -163,6 +184,7 @@ def resolve_type_id(type_id: int) -> str: 0x0000: "success", 0x0005: "invalid parameter / not supported", 0x0006: "unknown command", + 0x0E01: "door unlocked / safety interlock (command not allowed while door open)", 0x0200: "hardware error", 0x020A: "hardware not ready / axis error", } @@ -319,7 +341,9 @@ def _parse_type_seq( # Store as ParameterType with the base wire type from byte [i+2] width = end - i base_type = ints[i + 2] if i + 2 < len(ints) else 0 - result.append(ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width)) + result.append( + ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width) + ) i = end else: # Standard 3-byte reference: [sentinel, source_id, ref_id] @@ -509,7 +533,7 @@ class StructInfo: struct_id: int name: str fields: Dict[str, "ParameterType"] # field_name -> ParameterType - interface_id: Optional[int] = None # Interface this struct was defined on + interface_id: Optional[int] = None # Interface this struct was defined on @property def field_type_names(self) -> Dict[str, str]: @@ -561,9 +585,9 @@ def print_summary(self) -> None: """Print global pool summary.""" print(f"GlobalTypePool: {len(self.structs)} structs, {len(self.enums)} enums") for i, s in enumerate(self.structs): - print(f" struct[{i+1}]: {s.name} ({len(s.fields)} fields)") + print(f" struct[{i + 1}]: {s.name} ({len(s.fields)} fields)") for i, e in enumerate(self.enums): - print(f" enum[{i+1}]: {e.name} ({len(e.values)} values)") + print(f" enum[{i + 1}]: {e.name} ({len(e.values)} values)") # GetStructs wire format (device sends 4 separate array fragments): @@ -663,7 +687,9 @@ def parse_response_parameters(cls, data: bytes) -> dict: # use parse_next_raw() to avoid UTF-8 decode failure on bytes 0x80-0xFF. _, flags, _, param_types_payload = parser.parse_next_raw() if flags & PADDED_FLAG: - param_types_payload = param_types_payload[:-1] if param_types_payload else param_types_payload + param_types_payload = ( + param_types_payload[:-1] if param_types_payload else param_types_payload + ) param_types_payload = param_types_payload.rstrip(b"\x00") # STRING null terminator all_types = _parse_type_ids(param_types_payload) else: @@ -694,7 +720,9 @@ def parse_response_parameters(cls, data: bytes) -> dict: if label: return_labels.append(label) else: - logger.warning("Unknown introspection type category for type_id=%d; treating as parameter", pt.type_id) + logger.warning( + "Unknown introspection type category for type_id=%d; treating as parameter", pt.type_id + ) parameter_types.append(pt) if label: parameter_labels.append(label) @@ -1015,9 +1043,7 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] offset += cnt return result - async def get_structs_raw( - self, address: Address, interface_id: int - ) -> tuple[bytes, List[dict]]: + async def get_structs_raw(self, address: Address, interface_id: int) -> tuple[bytes, List[dict]]: """Get raw GetStructs response bytes and a fragment-by-fragment breakdown. Use this to see exactly what the device sends so response parsing can @@ -1029,9 +1055,7 @@ async def get_structs_raw( print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") """ command = GetStructsCommand(address, interface_id) - result = await self.backend.send_command( - command, ensure_connection=False, return_raw=True - ) + result = await self.backend.send_command(command, ensure_connection=False, return_raw=True) (params,) = result return params, inspect_hoi_params(params) @@ -1072,8 +1096,8 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI # Walk type_bytes with a byte-level cursor (variable width: 1 byte for simple # types, 3 bytes for 0xE8 complex references). field_counts gives the number # of *logical* fields per struct, not the number of bytes to consume. - byte_offset = 0 # cursor into type_bytes - name_offset = 0 # cursor into field_names + byte_offset = 0 # cursor into type_bytes + name_offset = 0 # cursor into field_names result: List[StructInfo] = [] for i, cnt in enumerate(field_counts): name = struct_names[i] if i < len(struct_names) else f"Struct_{i}" @@ -1255,7 +1279,9 @@ async def build_global_type_pool( logger.info( "Global type pool built: %d structs, %d enums from %d global objects", - len(pool.structs), len(pool.enums), len(global_addresses), + len(pool.structs), + len(pool.enums), + len(global_addresses), ) return pool @@ -1392,6 +1418,7 @@ def parse_error_u8_array_message(error_string: str) -> Optional[str]: with the instrument's human-readable message. Returns that value or None. """ import re + # Match U8_ARRAY= then either quoted content or rest until "; " or " [" or end m = re.search(r"U8_ARRAY=(?:\"([^\"]*)\"|([^;[\]]*?)(?:\s*;\s*|\s*\[|$))", error_string) if not m: @@ -1463,8 +1490,12 @@ async def diagnose_error( address, interface_id, method_id, hc_result = parsed error_text = self.parse_error_u8_array_message(error_string) or "" return await self.resolve_error( - address, interface_id, method_id, - registry=registry, hc_result=hc_result, error_text=error_text, + address, + interface_id, + method_id, + registry=registry, + hc_result=hc_result, + error_text=error_text, ) @@ -1510,6 +1541,7 @@ def _get_nested_dataclass(annotation): if inner_args: base_type = inner_args[0] import dataclasses + if dataclasses.is_dataclass(base_type): return base_type return None @@ -1518,6 +1550,7 @@ def _get_nested_dataclass(annotation): @dataclass class FieldMismatch: """One field-level mismatch between hand-crafted and introspected definitions.""" + field_name: str issue: str # e.g. "missing", "extra", "type mismatch", "order mismatch" expected: str = "" @@ -1533,6 +1566,7 @@ def __str__(self): @dataclass class ValidationResult: """Result of comparing a hand-crafted dataclass against introspection.""" + name: str passed: bool = False mismatches: List[FieldMismatch] = field(default_factory=list) @@ -1588,22 +1622,26 @@ def validate_struct( # 1. Field count if len(hand_names) != len(intro_names): - mismatches.append(FieldMismatch( - field_name="(count)", - issue="field count mismatch", - expected=str(len(intro_names)), - actual=str(len(hand_names)), - )) + mismatches.append( + FieldMismatch( + field_name="(count)", + issue="field count mismatch", + expected=str(len(intro_names)), + actual=str(len(hand_names)), + ) + ) # 2. Field names (order-aware) for i, (hn_norm, in_norm) in enumerate(zip(hand_norm, intro_norm)): if hn_norm != in_norm: - mismatches.append(FieldMismatch( - field_name=hand_names[i], - issue=f"name mismatch at position {i}", - expected=intro_names[i], - actual=hand_names[i], - )) + mismatches.append( + FieldMismatch( + field_name=hand_names[i], + issue=f"name mismatch at position {i}", + expected=intro_names[i], + actual=hand_names[i], + ) + ) # 3. Extra / missing fields hand_set = set(hand_norm) @@ -1618,7 +1656,9 @@ def validate_struct( mismatches.append(FieldMismatch(field_name=original_intro, issue="missing in hand-crafted")) for extra_norm in hand_set - intro_set: original_hand = hand_map[extra_norm] - mismatches.append(FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)")) + mismatches.append( + FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)") + ) # 4. Field types (where names match) for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): @@ -1632,22 +1672,29 @@ def validate_struct( if hand_type_id is not None and hand_type_id != intro_pt.type_id: try: from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import HamiltonDataType + expected_name = HamiltonDataType(intro_pt.type_id).name actual_name = HamiltonDataType(hand_type_id).name except ValueError: expected_name = str(intro_pt.type_id) actual_name = str(hand_type_id) - mismatches.append(FieldMismatch( - field_name=hand_name, - issue="type mismatch", - expected=expected_name, - actual=actual_name, - )) + mismatches.append( + FieldMismatch( + field_name=hand_name, + issue="type mismatch", + expected=expected_name, + actual=actual_name, + ) + ) # 5. Recursive validation for nested structs - if (pool is not None and intro_pt.is_complex - and intro_pt.source_id is not None and intro_pt.ref_id is not None - and intro_pt.type_id == 30): # STRUCTURE + if ( + pool is not None + and intro_pt.is_complex + and intro_pt.source_id is not None + and intro_pt.ref_id is not None + and intro_pt.type_id == 30 + ): # STRUCTURE nested_cls = _get_nested_dataclass(annotation) if nested_cls: if intro_pt.source_id == 1: @@ -1655,9 +1702,7 @@ def validate_struct( nested_struct = pool.resolve_struct(intro_pt.ref_id) elif intro_pt.source_id == 0 and introspected.interface_id is not None: # Same-interface ref: look up within that interface's struct group - nested_struct = pool.resolve_struct_local( - introspected.interface_id, intro_pt.ref_id - ) + nested_struct = pool.resolve_struct_local(introspected.interface_id, intro_pt.ref_id) else: nested_struct = None if nested_struct: @@ -1696,16 +1741,18 @@ def validate_command( result = ValidationResult(name=f"{command_cls.__name__} (cmd={cmd_id})") if cmd_id is None: - result.mismatches.append(FieldMismatch( - field_name="(class)", issue="no command_id attribute")) + result.mismatches.append(FieldMismatch(field_name="(class)", issue="no command_id attribute")) result.passed = False return result # Find matching introspected method method = registry.get_method(interface_id, cmd_id) if method is None: - result.mismatches.append(FieldMismatch( - field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]")) + result.mismatches.append( + FieldMismatch( + field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]" + ) + ) result.passed = False return result @@ -1713,9 +1760,7 @@ def validate_command( # Get command's payload fields (exclude 'dest' and class-level attrs) hints = typing.get_type_hints(command_cls, include_extras=True) - payload_fields = [ - f for f in dc.fields(command_cls) if f.name != "dest" - ] + payload_fields = [f for f in dc.fields(command_cls) if f.name != "dest"] # Match struct payload fields to introspected parameter types positionally struct_fields = [ @@ -1724,7 +1769,8 @@ def validate_command( if _get_nested_dataclass(hints.get(pf.name)) is not None ] struct_params = [ - pt for pt in method.parameter_types + pt + for pt in method.parameter_types if pt.is_complex and pt.source_id is not None and pt.ref_id is not None ] @@ -1746,4 +1792,3 @@ def validate_command( result.passed = all(c.passed for c in result.children) and len(result.mismatches) == 0 return result - diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index a2daea8201b..ed29cd7fffa 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -223,34 +223,44 @@ def inspect_hoi_params(params: bytes) -> List[dict]: length = int.from_bytes(params[offset + 2 : offset + 4], "little") payload_end = offset + 4 + length if payload_end > len(params): - out.append({ - "type_id": type_id, - "flags": flags, - "length": length, - "payload_hex": "", - "payload_len": 0, - "decoded": f"", - }) + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": "", + "payload_len": 0, + "decoded": f"", + } + ) break data = params[offset + 4 : payload_end] hex_preview = data.hex() if len(data) <= 40 else data[:40].hex() + "..." try: decoded = decode_fragment(type_id, data) if isinstance(decoded, bytes): - decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" - decoded_repr = repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + decoded = ( + decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" + ) + decoded_repr = ( + repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + ) if isinstance(decoded, list): - decoded_repr = f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + decoded_repr = ( + f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + ) except Exception as e: decoded_repr = f"" - out.append({ - "type_id": type_id, - "flags": flags, - "length": length, - "payload_hex": hex_preview, - "payload_len": len(data), - "decoded": decoded_repr, - }) + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": hex_preview, + "payload_len": len(data), + "decoded": decoded_repr, + } + ) offset = payload_end return out @@ -300,7 +310,9 @@ def _parse_hamilton_error_fragments(params: bytes) -> List[str]: b = bytes(decoded) s = b.decode("utf-8", errors="replace").rstrip("\x00").strip() # Strip leading control characters (e.g. length or flags before message text) - s = s.lstrip("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f").strip() + s = s.lstrip( + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + ).strip() if s and any(c.isprintable() or c.isspace() for c in s): decoded = s out.append(f"{type_name}={decoded}") @@ -356,9 +368,7 @@ def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: "list of bytes (STRUCTURE_ARRAY). Use get_structs_raw() and " "inspect_hoi_params() to see the exact wire format." ) - values[f.name] = [ - parse_into_struct(HoiParamsParser(p), element_type) for p in raw - ] + values[f.name] = [parse_into_struct(HoiParamsParser(p), element_type) for p in raw] else: # Count then N flat fragments (count-prefixed stream) count = int(raw) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 03b2f3ff6dd..b5f6ee0ba43 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -1020,6 +1020,7 @@ class Response: def test_auto_decode_fallback_no_response_class(self): """Command without Response returns None when params empty.""" + class CommandNoResponse(HamiltonCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -1042,6 +1043,7 @@ class CommandNoResponse(HamiltonCommand): def test_auto_decode_fallback_parse_response_parameters(self): """Command with parse_response_parameters override but no Response uses override.""" + class CommandWithOverride(HamiltonCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 @@ -1318,14 +1320,7 @@ def test_get_object_command_interpret_response(self): from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import GetObjectCommand cmd = GetObjectCommand(Address(0, 0, 0)) - params = ( - HoiParams() - .add("RootObj", Str) - .add("1.0", Str) - .add(3, I32) - .add(2, I32) - .build() - ) + params = HoiParams().add("RootObj", Str).add("1.0", Str).add(3, I32).add(2, I32).build() hoi = HoiPacket( interface_id=0, action_code=Hoi2Action.COMMAND_RESPONSE, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 9f423a2fe41..34a7f9a25d4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -338,7 +338,9 @@ async def run_setup_loop(self) -> None: found = sorted(name for name in self.interfaces if self.has_interface(name)) optional_missing = sorted( - name for name, spec in self.interfaces.items() if not spec.required and not self.has_interface(name) + name + for name, spec in self.interfaces.items() + if not spec.required and not self.has_interface(name) ) logger.info("Interfaces: %s", ", ".join(found)) if optional_missing: @@ -700,7 +702,9 @@ async def _initialize_connection(self): # Controller module is 2, node is client_id, object 65535 for general addressing self.client_address = Address(2, response.client_id, 65535) - logger.info("Connection initialized (Client ID: %s, Address: %s)", self._client_id, self.client_address) + logger.info( + "Connection initialized (Client ID: %s, Address: %s)", self._client_id, self.client_address + ) async def _register_client(self): """Register client using Protocol 3.""" @@ -843,7 +847,6 @@ async def _discover_globals(self): self._global_object_addresses = global_objects logger.debug("[DISCOVER_GLOBALS] Found %s global objects", len(global_objects)) - def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: """Parse registration response options to extract object addresses. @@ -958,7 +961,9 @@ async def send_command( try: if command.source_address is None: if self.client_address is None: - raise RuntimeError("Backend not initialized - call setup() first to assign client_address") + raise RuntimeError( + "Backend not initialized - call setup() first to assign client_address" + ) command.source_address = self.client_address command.sequence_number = self._allocate_sequence_number(command.dest_address) @@ -995,9 +1000,7 @@ async def send_command( self._type_registries[error_addr] = await intro.build_type_registry(error_addr) except Exception: raise RuntimeError(enriched_msg) - diagnostic = await intro.diagnose_error( - enriched_msg, self._type_registries[error_addr] - ) + diagnostic = await intro.diagnose_error(enriched_msg, self._type_registries[error_addr]) raise RuntimeError(diagnostic) logger.debug(enriched_msg) return None @@ -1066,5 +1069,3 @@ def serialize(self) -> dict: "client_id": self._client_id, "registry_paths": list(self._registry._objects.keys()), } - - diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 24f2dc61f3d..ae391cac91d 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -660,9 +660,7 @@ def __init__( name=name, size_x=size_x, size_y=size_y, size_z=size_z, origin=origin, category=category ) if with_core_grippers: - self.assign_child_resource( - prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5) - ) + self.assign_child_resource(prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5)) spots_list: List[ResourceHolder] = [] for column in range(2): for row in range(4): @@ -673,7 +671,9 @@ def __init__( size_x=127.76, size_y=92, size_z=12.5, - child_location=Coordinate(0, 1.5, 3.75), # Adjusted for plastic corner mounts TODO: Validate on other systems + child_location=Coordinate( + 0, 1.5, 3.75 + ), # Adjusted for plastic corner mounts TODO: Validate on other systems ) self.assign_child_resource(spot, location=Coordinate(x, y, 0)) spots_list.append(spot) From 3c300c3c1f3680b59fbf99e6933032fd49f2f74e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:32:38 -0700 Subject: [PATCH 28/42] Formatting, Async fix for 3.9 Tests --- .../backends/hamilton/__init__.py | 2 +- .../backends/hamilton/nimbus_backend.py | 10 +++++----- .../backends/hamilton/nimbus_backend_tests.py | 14 +++++++------- .../backends/hamilton/prep_backend.py | 17 ++++++++--------- .../backends/hamilton/prep_backend_tests.py | 15 +++++++++++---- .../backends/hamilton/prep_commands.py | 13 +++++++------ .../backends/hamilton/tcp/introspection.py | 6 +++--- .../backends/hamilton/tcp/messages.py | 4 ++-- .../backends/hamilton/tcp/tcp_tests.py | 18 +++++++++--------- .../backends/hamilton/tcp_backend.py | 2 +- 10 files changed, 54 insertions(+), 47 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/__init__.py index f2dca3434d8..7d19b3f863e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/backends/hamilton/__init__.py @@ -1,7 +1,7 @@ """Hamilton backends for liquid handling.""" from .base import HamiltonLiquidHandler -from .pump import Pump # TODO: move elsewhere. from .prep_backend import PrepBackend +from .pump import Pump # TODO: move elsewhere. from .STAR_backend import STAR from .vantage_backend import Vantage diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index a9a017c0071..ca65ad28821 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -12,27 +12,27 @@ import enum import logging from dataclasses import dataclass -from typing import Dict, List, Optional, overload, Sequence, Tuple, TypeVar, Union +from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union, overload +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + I32, + U16, Bool, BoolArray, I16Array, - I32, I32Array, - U16, U16Array, U32Array, ) -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( - HamiltonTCPClient, HamiltonInterfaceResolver, + HamiltonTCPClient, InterfaceSpec, ) from pylabrobot.liquid_handling.standard import ( diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 528d4de4d2d..733987548dd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -30,18 +30,12 @@ UnlockDoor, _get_tip_type_from_tip, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ObjectInfo from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, HoiParams, HoiParamsParser, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - Bool, - I16Array, - U16, -) -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ObjectInfo from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( Address, HarpPacket, @@ -49,6 +43,12 @@ IpPacket, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol, Hoi2Action +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + U16, + Bool, + I16Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.standard import ( Drop, Pickup, diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index c2eb0fab399..85f55542c20 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -26,18 +26,21 @@ import logging import math import random -from typing import List, Optional, overload, Tuple, Union - -from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd +from typing import List, Optional, Tuple, Union, overload from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( - HamiltonTCPClient, HamiltonInterfaceResolver, + HamiltonTCPClient, InterfaceSpec, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -54,10 +57,6 @@ SingleChannelAspiration, SingleChannelDispense, ) -from pylabrobot.liquid_handling.liquid_classes.hamilton import ( - HamiltonLiquidClass, - get_star_liquid_class, -) from pylabrobot.resources import Coordinate, Tip from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py index 7bb9f8aa754..c28568fe2c4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py @@ -5,17 +5,18 @@ All tests mock client.send_command — no real TCP connection required. """ +import asyncio import math import unittest import unittest.mock +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd from pylabrobot.liquid_handling.backends.hamilton.prep_backend import ( PrepBackend, _absolute_z_from_well, _build_container_segments, _effective_radius, ) -from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.liquid_classes.hamilton import get_star_liquid_class from pylabrobot.liquid_handling.standard import ( @@ -38,14 +39,13 @@ Plate, PrepDeck, Rotation, + TipPickupMethod, + TipSize, Trash, Well, hamilton_96_tiprack_300uL_filter, - TipPickupMethod, - TipSize, ) - # ============================================================================= # Setup helpers # ============================================================================= @@ -262,6 +262,13 @@ def make_op(liquid_height): class TestPrepBackendUnit(unittest.TestCase): """Backend construction, properties, and traverse height resolution.""" + def setUp(self): + super().setUp() + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) + def test_num_channels_raises_before_setup(self): backend = PrepBackend(host="localhost", port=2000) with self.assertRaises(RuntimeError): diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py index 3d35e7651c8..85bf0d9ec9f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -14,28 +14,29 @@ from typing import Annotated, Optional, Tuple from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.standard import SingleChannelAspiration from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - EnumArray, F32, I8, I16, - I16Array, I64, + U16, + U32, + EnumArray, + I16Array, PaddedBool, PaddedU8, Str, Struct, StructArray, - U16, - U32, U8Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( Enum as WEnum, ) - +from pylabrobot.liquid_handling.standard import SingleChannelAspiration # ============================================================================= # Enums (mirrored from Prep protocol spec) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index d2809d28a7d..a9d1a10c522 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -36,15 +36,15 @@ from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + U8, + U16, + U32, HamiltonDataType, I8Array, I32Array, Str, StrArray, - U8, U8Array, - U16, - U32, U32Array, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index ed29cd7fffa..7e99861a515 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -14,7 +14,8 @@ from __future__ import annotations -from dataclasses import dataclass, fields as dc_fields +from dataclasses import dataclass +from dataclasses import fields as dc_fields from typing import Any, List, cast, get_args, get_origin, get_type_hints from pylabrobot.io.binary import Reader, Writer @@ -34,7 +35,6 @@ decode_fragment, ) - PADDED_FLAG = 0x01 # ============================================================================ diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index b5f6ee0ba43..d4638efbae5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -38,25 +38,25 @@ RegistrationOptionType, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - Bool, - BoolArray, - CountedFlatArray, F32, - F32Array, F64, - HamiltonDataType, I8, I16, I32, - I32Array, I64, - Str, - StrArray, U8, U16, - U16Array, U32, U64, + Bool, + BoolArray, + CountedFlatArray, + F32Array, + HamiltonDataType, + I32Array, + Str, + StrArray, + U16Array, decode_fragment, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 89a38462e23..c28e9348fae 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -56,9 +56,9 @@ CommandResponse, InitMessage, InitResponse, - parse_hamilton_error_params, RegistrationMessage, RegistrationResponse, + parse_hamilton_error_params, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( From 5b527895ccd68e8481fdd1dbeaf6ac8533ac16ee Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:47:15 -0700 Subject: [PATCH 29/42] Demo Notebooks in Docs --- .../user_guide/00_liquid-handling/_liquid-handling.rst | 1 + .../hamilton-nimbus/_hamilton-nimbus.md | 10 ++++++++++ .../00_liquid-handling/hamilton-prep/_hamilton-prep.md | 9 ++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 421665d8dc2..1cacb43d165 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -17,6 +17,7 @@ Examples: hamilton-star/_hamilton-star hamilton-vantage/_hamilton-vantage hamilton-prep/_hamilton-prep + hamilton-nimbus/_hamilton-nimbus opentrons-ot2/hello-world tecan-evo/_tecan-evo plate-washing/plate-washing diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md b/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md new file mode 100644 index 00000000000..36a1da16935 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md @@ -0,0 +1,10 @@ +# Hamilton Nimbus + +Basic Support for Channels. + +```{toctree} +:maxdepth: 1 +:hidden: + +nimbus_basic_demo +``` diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md b/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md index 1eceeccc73c..9943ce2ae54 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md @@ -1,3 +1,10 @@ # Hamilton Prep -Coming soon. See [https://github.com/PyLabRobot/pylabrobot/pull/407](https://github.com/PyLabRobot/pylabrobot/pull/407). +Channel and CORE gripper support. + +```{toctree} +:maxdepth: 1 +:hidden: + +prep_basic_demo +``` From a12c72ba45c63363501a91af059515393f47cbf9 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 13 Mar 2026 23:51:13 +0000 Subject: [PATCH 30/42] contribute further error messages (with certainty clause) --- .../liquid_handling/backends/hamilton/tcp/introspection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index a9d1a10c522..33073cd6850 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -187,6 +187,9 @@ def resolve_type_id(type_id: int) -> str: 0x0E01: "door unlocked / safety interlock (command not allowed while door open)", 0x0200: "hardware error", 0x020A: "hardware not ready / axis error", + # Empirically identified — require further validation: + 0x0F03: "drive initialization failed (reference/homing run failure) [empirical, needs validation]", + 0x0F08: "tip pickup failed (tip not detected at expected position) [empirical, needs validation]", } From 0533df27175c696640cb658ebf5edc5d208f47ba Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sat, 14 Mar 2026 02:11:16 +0000 Subject: [PATCH 31/42] =?UTF-8?q?Add=20sense=5Ftip=5Fpresence()=20to=20Pre?= =?UTF-8?q?pBackend=20=E2=80=94=20reads=20physical=20sleeve=20displacement?= =?UTF-8?q?=20sensor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backends/hamilton/prep_backend.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 85f55542c20..b7c8e6f9583 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -241,6 +241,8 @@ def __init__( self._num_channels: Optional[int] = None self._has_mph: Optional[bool] = None self._gripper_tool_on: bool = False + self._channel_sleeve_sensor_addrs: list[Address] = [] + self._channel_zdrive_addrs: list[Address] = [] def _has_interface(self, name: str) -> bool: """Return True if the interface was resolved and is present.""" @@ -313,6 +315,9 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): # Resolve all interfaces (required fail-fast; optional log and continue) await self._resolver.run_setup_loop() + # Discover per-channel drive addresses from the object tree. + await self._discover_channel_drives() + if force_initialize: await self._run_initialize(smart=smart) logger.info("Prep initialization complete (force_initialize=True)") @@ -346,6 +351,84 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self.setup_finished = True + async def _discover_channel_drives(self) -> None: + """Walk the MLPrepRoot object tree to find pipettor channel drive addresses by name. + + Walks: MLPrepRoot → "Channel Root" → "Channel" → "Squeeze" → "SDrive" (sleeve sensor) + MLPrepRoot → "Channel Root" → "Channel" → "ZAxis" → "ZDrive" + + Populates ``_channel_sleeve_sensor_addrs`` and ``_channel_zdrive_addrs``. + All lookups are by object name, not hardcoded object IDs. + + Skips "MPH Channel Root" — only discovers dual-channel pipettor channels ("Channel Root"). + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import HamiltonIntrospection + + self._channel_sleeve_sensor_addrs = [] + self._channel_zdrive_addrs = [] + + intro = HamiltonIntrospection(self.client) + root_addrs = self.client._registry.get_root_addresses() + if not root_addrs: + return + + root_addr = root_addrs[0] + root_info = await intro.get_object(root_addr) + + async def find_child_by_name(parent_addr, parent_info, name): + """Find a direct child object by name. Returns (address, info) or (None, None).""" + for i in range(parent_info.subobject_count): + try: + child_addr = await intro.get_subobject_address(parent_addr, i) + child_info = await intro.get_object(child_addr) + if child_info.name == name: + return child_addr, child_info + except Exception: + continue + return None, None + + for i in range(root_info.subobject_count): + try: + sub_addr = await intro.get_subobject_address(root_addr, i) + sub_info = await intro.get_object(sub_addr) + except Exception: + continue + + if sub_info.name != "Channel Root": + continue + + # Channel Root → Channel → Squeeze → SDrive + channel_addr, channel_info = await find_child_by_name(sub_addr, sub_info, "Channel") + if channel_addr is None: + logger.warning("Channel Root on node %d has no 'Channel' child, skipping", sub_addr.node) + continue + + squeeze_addr, squeeze_info = await find_child_by_name(channel_addr, channel_info, "Squeeze") + sdrive_addr = None + if squeeze_addr is not None and squeeze_info is not None: + sdrive_addr, _ = await find_child_by_name(squeeze_addr, squeeze_info, "SDrive") + + # Channel Root → Channel → ZAxis → ZDrive + zaxis_addr, zaxis_info = await find_child_by_name(channel_addr, channel_info, "ZAxis") + zdrive_addr = None + if zaxis_addr is not None and zaxis_info is not None: + zdrive_addr, _ = await find_child_by_name(zaxis_addr, zaxis_info, "ZDrive") + + if sdrive_addr is not None: + self._channel_sleeve_sensor_addrs.append(sdrive_addr) + else: + logger.warning("Channel Root on node %d: could not find Squeeze.SDrive", sub_addr.node) + + if zdrive_addr is not None: + self._channel_zdrive_addrs.append(zdrive_addr) + else: + logger.warning("Channel Root on node %d: could not find ZAxis.ZDrive", sub_addr.node) + + logger.debug("Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s", + sub_addr.node, sdrive_addr, zdrive_addr) + + logger.info("Discovered %d pipettor channel drive pairs", len(self._channel_sleeve_sensor_addrs)) + async def _run_initialize(self, smart: bool): """Send PrepCmd.PrepInitialize to MLPrep (shared by setup).""" await self.client.send_command( @@ -1555,6 +1638,55 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: return False return True + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def sense_tip_presence(self) -> list[bool]: + """Sense whether a tip is physically present on each pipettor channel via the sleeve sensor. + + Reads the physical sleeve displacement sensor (GetTipPresent, cmd=15) on each + channel's SDrive sub-object. The sensor responds in real-time to sleeve + displacement — verified by manual sleeve push tests without any tip pickup. + + Note: the firmware exposes this sensor through the SDrive (squeezer drive) object + at object_id 514, but it reads the sleeve displacement sensor independently of + the squeeze motor state. + + Channel addresses are discovered from the object tree at setup time + (stored in ``_channel_sleeve_sensor_addrs``), so this works regardless of the + node IDs assigned by the firmware on a given instrument. + + Returns: + List of bools, one per channel (index 0=rear, 1=front). True if tip detected. + """ + import struct as _struct + + if not self._channel_sleeve_sensor_addrs: + raise RuntimeError( + "No channel sleeve sensor addresses discovered. Call setup() first." + ) + + results: list[bool] = [] + for addr in self._channel_sleeve_sensor_addrs: + Cmd = type( + "_GetTipPresent", + (PrepCmd._PrepStatusQuery,), + {"command_id": 15, "__annotations__": {"dest": Address}}, + ) + raw = await self.client.send_command( + Cmd(dest=addr), + return_raw=True, + raise_on_error=False, + ) + if raw is None or len(raw[0]) < 8: + results.append(False) + else: + val = _struct.unpack_from(" Date: Sat, 14 Mar 2026 02:32:52 +0000 Subject: [PATCH 32/42] Add firmware version queries --- .../backends/hamilton/prep_backend.py | 141 +++++++++++++++++- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index b7c8e6f9583..f9b976da60e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -243,6 +243,9 @@ def __init__( self._gripper_tool_on: bool = False self._channel_sleeve_sensor_addrs: list[Address] = [] self._channel_zdrive_addrs: list[Address] = [] + self._channel_node_info_addrs: list[Address] = [] + self._mlprep_cpu_addr: Optional[Address] = None + self._module_info_addr: Optional[Address] = None def _has_interface(self, name: str) -> bool: """Return True if the interface was resolved and is present.""" @@ -352,20 +355,26 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self.setup_finished = True async def _discover_channel_drives(self) -> None: - """Walk the MLPrepRoot object tree to find pipettor channel drive addresses by name. + """Walk the MLPrepRoot object tree to discover per-channel and module addresses by name. - Walks: MLPrepRoot → "Channel Root" → "Channel" → "Squeeze" → "SDrive" (sleeve sensor) - MLPrepRoot → "Channel Root" → "Channel" → "ZAxis" → "ZDrive" + Channel drives (per pipettor channel, skipping "MPH Channel Root"): + MLPrepRoot → "Channel Root" → "Channel" → "Squeeze" → "SDrive" (sleeve sensor) + MLPrepRoot → "Channel Root" → "Channel" → "ZAxis" → "ZDrive" + MLPrepRoot → "Channel Root" → "NodeInformation" - Populates ``_channel_sleeve_sensor_addrs`` and ``_channel_zdrive_addrs``. - All lookups are by object name, not hardcoded object IDs. + Module-level objects (for firmware version queries): + MLPrepRoot → "MLPrepCpu" + MLPrepRoot → "PipettorRoot" → "ModuleInformation" - Skips "MPH Channel Root" — only discovers dual-channel pipettor channels ("Channel Root"). + All lookups are by object name, not hardcoded object IDs. """ from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import HamiltonIntrospection self._channel_sleeve_sensor_addrs = [] self._channel_zdrive_addrs = [] + self._channel_node_info_addrs = [] + self._mlprep_cpu_addr = None + self._module_info_addr = None intro = HamiltonIntrospection(self.client) root_addrs = self.client._registry.get_root_addresses() @@ -394,6 +403,20 @@ async def find_child_by_name(parent_addr, parent_info, name): except Exception: continue + # MLPrepCpu — controller firmware version info + if sub_info.name == "MLPrepCpu": + self._mlprep_cpu_addr = sub_addr + logger.debug("Discovered MLPrepCpu at %s", sub_addr) + continue + + # PipettorRoot → ModuleInformation + if sub_info.name == "PipettorRoot": + mod_addr, _ = await find_child_by_name(sub_addr, sub_info, "ModuleInformation") + if mod_addr is not None: + self._module_info_addr = mod_addr + logger.debug("Discovered ModuleInformation at %s", mod_addr) + continue + if sub_info.name != "Channel Root": continue @@ -414,6 +437,9 @@ async def find_child_by_name(parent_addr, parent_info, name): if zaxis_addr is not None and zaxis_info is not None: zdrive_addr, _ = await find_child_by_name(zaxis_addr, zaxis_info, "ZDrive") + # Channel Root → NodeInformation + node_info_addr, _ = await find_child_by_name(sub_addr, sub_info, "NodeInformation") + if sdrive_addr is not None: self._channel_sleeve_sensor_addrs.append(sdrive_addr) else: @@ -424,8 +450,13 @@ async def find_child_by_name(parent_addr, parent_info, name): else: logger.warning("Channel Root on node %d: could not find ZAxis.ZDrive", sub_addr.node) - logger.debug("Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s", - sub_addr.node, sdrive_addr, zdrive_addr) + if node_info_addr is not None: + self._channel_node_info_addrs.append(node_info_addr) + else: + logger.warning("Channel Root on node %d: could not find NodeInformation", sub_addr.node) + + logger.debug("Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s, NodeInfo=%s", + sub_addr.node, sdrive_addr, zdrive_addr, node_info_addr) logger.info("Discovered %d pipettor channel drive pairs", len(self._channel_sleeve_sensor_addrs)) @@ -1687,6 +1718,100 @@ async def sense_tip_presence(self) -> list[bool]: return results + # --------------------------------------------------------------------------- + # Firmware version queries + # --------------------------------------------------------------------------- + + @staticmethod + def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: + """Decode a string from a raw HOI response. + + Hamilton string wire format: 0x0F + type_byte + u16 length + chars. + type_byte is 0x00 (plain) or 0x01 (with null terminator); both are handled. + """ + if raw is None: + return None + data = raw[0] + i = 0 + while i < len(data) - 3: + if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): + slen = int.from_bytes(data[i + 2 : i + 4], "little") + if slen > 0 and i + 4 + slen <= len(data): + return data[i + 4 : i + 4 + slen].decode("utf-8", errors="replace").rstrip("\x00") + i += 1 + return None + + async def _query_firmware_string(self, addr: Address, cmd_id: int, iface_id: int = 3) -> Optional[str]: + """Send a status query and decode the string response.""" + Cmd = type( + "_FWQuery", + (PrepCmd._PrepStatusQuery,), + {"command_id": cmd_id, "interface_id": iface_id, "__annotations__": {"dest": Address}}, + ) + raw = await self.client.send_command(Cmd(dest=addr), return_raw=True, raise_on_error=False) + return self._decode_firmware_string(raw) + + async def request_firmware_version(self) -> Optional[str]: + """Request the instrument controller firmware version string. + + Returns a string like "MLPrep Runtime V1.2.2.444 99020-02 Rev G", + or None if MLPrepCpu was not discovered. + + Analogous to STARBackend.request_firmware_version(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=8) + + async def request_device_serial_number(self) -> Optional[str]: + """Request the instrument serial number. + + Analogous to STARBackend.request_device_serial_number(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=9) + + async def request_bootloader_version(self) -> Optional[str]: + """Request the instrument bootloader version string.""" + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=2, iface_id=2) + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Request the firmware version string for a pipettor channel. + + Args: + channel: Channel index (0=rear, 1=front). + + Analogous to STARBackend.request_pip_channel_version(). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string(self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1) + + async def request_pip_channel_serial_number(self, channel: int) -> Optional[str]: + """Request the serial number for a pipettor channel. + + Args: + channel: Channel index (0=rear, 1=front). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string(self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1) + + async def request_module_version(self) -> Optional[str]: + """Request the pipettor module version string (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=8) + + async def request_module_part_number(self) -> Optional[str]: + """Request the firmware part number (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=5) + # --------------------------------------------------------------------------- # MLPrep convenience methods # --------------------------------------------------------------------------- From 25c82ef833dd9856535da1a3e8a60e17dcb3c087 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sat, 14 Mar 2026 10:42:06 +0000 Subject: [PATCH 33/42] expose `print_firmware_tree` convenience method --- .../backends/hamilton/prep_backend.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index f9b976da60e..563a364d9c8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1812,6 +1812,79 @@ async def request_module_part_number(self) -> Optional[str]: return None return await self._query_firmware_string(self._module_info_addr, cmd_id=5) + # --------------------------------------------------------------------------- + # Object tree inspection + # --------------------------------------------------------------------------- + + async def print_firmware_tree(self) -> None: + """Walk the full firmware object tree and print a formatted tree representation. + + Each object shows its name, address, firmware version, method count, and child count. + Useful for diagnostics and understanding the instrument's firmware topology. + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import HamiltonIntrospection + + intro = HamiltonIntrospection(self.client) + root_addrs = self.client._registry.get_root_addresses() + if not root_addrs: + return "(no root objects discovered)" + + lines: list[str] = [] + + async def walk(addr, prefix="", is_last=True): + try: + obj = await intro.get_object(addr) + except Exception: + lines.append(f"{prefix}{'└── ' if is_last else '├── '}? @ {addr} (failed to query)") + return + + connector = "└── " if is_last else "├── " + version_str = f", version={obj.version}" if obj.version else "" + lines.append( + f"{prefix}{connector}{obj.name} @ {addr} " + f"(methods={obj.method_count}, children={obj.subobject_count}{version_str})" + ) + + child_prefix = prefix + (" " if is_last else "│ ") + children_found = [] + for i in range(obj.subobject_count): + try: + child_addr = await intro.get_subobject_address(addr, i) + child_obj = await intro.get_object(child_addr) + children_found.append((child_addr, child_obj)) + except Exception: + continue + + for idx, (child_addr, _) in enumerate(children_found): + await walk(child_addr, child_prefix, is_last=(idx == len(children_found) - 1)) + + for root_idx, root_addr in enumerate(root_addrs): + try: + root_obj = await intro.get_object(root_addr) + except Exception: + lines.append(f"? @ {root_addr} (failed to query)") + continue + + version_str = f", version={root_obj.version}" if root_obj.version else "" + lines.append( + f"{root_obj.name} @ {root_addr} " + f"(methods={root_obj.method_count}, children={root_obj.subobject_count}{version_str})" + ) + + children_found = [] + for i in range(root_obj.subobject_count): + try: + child_addr = await intro.get_subobject_address(root_addr, i) + child_obj = await intro.get_object(child_addr) + children_found.append((child_addr, child_obj)) + except Exception: + continue + + for idx, (child_addr, _) in enumerate(children_found): + await walk(child_addr, "", is_last=(idx == len(children_found) - 1)) + + print("\n".join(lines)) + # --------------------------------------------------------------------------- # MLPrep convenience methods # --------------------------------------------------------------------------- From 954495b602ad2f0b55e3971c79ccd4aaa49c2bdf Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sun, 15 Mar 2026 09:48:40 +0000 Subject: [PATCH 34/42] contribute likely `0x0011` error type --- .../liquid_handling/backends/hamilton/tcp/introspection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 33073cd6850..3b11a4402e6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -188,6 +188,7 @@ def resolve_type_id(type_id: int) -> str: 0x0200: "hardware error", 0x020A: "hardware not ready / axis error", # Empirically identified — require further validation: + 0x0011: "parameter value out of valid range [empirical, needs validation]", 0x0F03: "drive initialization failed (reference/homing run failure) [empirical, needs validation]", 0x0F08: "tip pickup failed (tip not detected at expected position) [empirical, needs validation]", } From 6f60e35db70beffa4114c3853875acd4ac975585 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 08:11:27 +0000 Subject: [PATCH 35/42] contribute likely rail specific out of bounds errors --- .../liquid_handling/backends/hamilton/tcp/introspection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 3b11a4402e6..d0dd3fccdcd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -190,6 +190,9 @@ def resolve_type_id(type_id: int) -> str: # Empirically identified — require further validation: 0x0011: "parameter value out of valid range [empirical, needs validation]", 0x0F03: "drive initialization failed (reference/homing run failure) [empirical, needs validation]", + 0x0F04: "X position out of allowed movement range [empirical, needs validation]", + 0x0F05: "Y position out of allowed movement range [empirical, needs validation]", + 0x0F06: "Z position out of allowed movement range [empirical, needs validation]", 0x0F08: "tip pickup failed (tip not detected at expected position) [empirical, needs validation]", } From 9cff116389dcd10931aa31298e166fb7454b7478 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 23:22:02 +0000 Subject: [PATCH 36/42] Add per-command read_timeout to HamiltonTCPClient.send_command The default 30s socket read timeout causes connection crashes during slow firmware operations (e.g. cLLD seeks descending 150mm at 2mm/s = 75s). The client interprets the timeout as a disconnection, triggers reconnection, and crashes the Prep's TCP stack - requiring a power cycle. Adds an optional read_timeout parameter to send_command() that threads through to _read_one_message() and its read_exact() calls. When None (default), uses the socket's existing 30s timeout - zero behavior change for all existing code. Backends can now pass longer timeouts for specific slow commands without affecting other operations. --- .../backends/hamilton/tcp_backend.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index c28e9348fae..d9c551a499a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -564,7 +564,9 @@ def is_connected(self) -> bool: """Check if the connection is currently established.""" return self._connected - async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: + async def _read_one_message( + self, timeout: Optional[float] = None + ) -> Union[RegistrationResponse, CommandResponse]: """Read one complete Hamilton packet and parse based on protocol. Hamilton packets are length-prefixed: @@ -574,6 +576,9 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse The method inspects the IP protocol field and, for Protocol 6 (HARP), also checks the HARP protocol field to dispatch correctly. + Args: + timeout: Read timeout in seconds. If None, uses the client's default. + Returns: Union[RegistrationResponse, CommandResponse]: Parsed response @@ -584,11 +589,11 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse """ # Read packet size (2 bytes, little-endian) - size_data = await self.read_exact(2) + size_data = await self.read_exact(2, timeout=timeout) packet_size = Reader(size_data).u16() # Read packet payload - payload_data = await self.read_exact(packet_size) + payload_data = await self.read_exact(packet_size, timeout=timeout) complete_data = size_data + payload_data # Parse IP packet to get protocol field (byte 2) @@ -912,6 +917,7 @@ async def send_command( ensure_connection: bool = True, return_raw: bool = False, raise_on_error: bool = True, + read_timeout: Optional[float] = None, ) -> Any: """Send Hamilton command and wait for response. @@ -974,7 +980,7 @@ async def send_command( logger.debug(f"{command.__class__.__name__} parameters: {log_params}") await self.write(message) - response_message = await self._read_one_message() + response_message = await self._read_one_message(timeout=read_timeout) assert isinstance(response_message, CommandResponse) action = Hoi2Action(response_message.hoi.action_code) From cfd4d7002249019b68cfbd76bdab206a907e55e0 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 17 Mar 2026 08:12:32 +0000 Subject: [PATCH 37/42] Add channel position queries and firmware-queried movement bounds Adds STAR-compatible channel position queries and movement bounds validation. Positions use typed firmware responses for reliable parsing. Movement bounds are queried per-channel from `PipettorService.GetChannelBounds` at setup and validated before every `move_to_position` call. --- .../backends/hamilton/prep_backend.py | 281 +++++++++++++++++- .../backends/hamilton/prep_commands.py | 46 +++ 2 files changed, 323 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 563a364d9c8..530ca304604 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -246,6 +246,7 @@ def __init__( self._channel_node_info_addrs: list[Address] = [] self._mlprep_cpu_addr: Optional[Address] = None self._module_info_addr: Optional[Address] = None + self._channel_bounds: list[dict] = [] def _has_interface(self, name: str) -> bool: """Return True if the interface was resolved and is present.""" @@ -352,6 +353,15 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self._config.has_mph, ) + # Cache per-channel movement bounds from firmware (required for move validation) + self._channel_bounds = await self.request_channel_bounds() + if not self._channel_bounds: + raise RuntimeError( + "Failed to query channel movement bounds (GetChannelBounds). " + "Cannot validate movement commands without bounds." + ) + logger.info("Channel bounds: %s", self._channel_bounds) + self.setup_finished = True async def _discover_channel_drives(self) -> None: @@ -1669,6 +1679,255 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: return False return True + # --------------------------------------------------------------------------- + # Channel position queries + # --------------------------------------------------------------------------- + + async def request_channel_bounds(self) -> list[dict]: + """Request per-channel movement bounds from the firmware. + + Queries PipettorService.GetChannelBounds (cmd=10). Returns one entry per + channel, ordered by channel index. Each entry is a dict with keys: + x_min, x_max, y_min, y_max, z_min, z_max (all in mm). + + These are the firmware-enforced limits — positions outside these ranges + will be rejected with 0x0F04 (X), 0x0F05 (Y), or 0x0F06 (Z). + Z bounds are for empty channels; with a tip attached the effective Z + minimum is higher. + + Returns: + List of dicts, one per channel. Each dict has keys: + x_min, x_max, y_min, y_max, z_min, z_max (all in mm). + """ + import struct as _struct + + # GetChannelBounds is on PipettorService (child of Pipettor), not MLPrepService + try: + await self.client.interfaces["MLPrepRoot.PipettorRoot.Pipettor.PipettorService"].resolve() + pip_svc = self.client.interfaces["MLPrepRoot.PipettorRoot.Pipettor.PipettorService"].address + except KeyError: + return [] + + raw = await self.client.send_command( + PrepCmd.PrepGetChannelBounds(dest=pip_svc), + return_raw=True, + raise_on_error=False, + ) + if raw is None: + return [] + + # Parse per-channel bounds from raw response. + # Each channel block: channel_enum (u32 at 0x20), then 6× f32 (at 0x28): + # x_min, x_max, y_min, y_max, z_min, z_max + data = raw[0] + _CHANNEL_ENUM_TO_IDX = {v: k for k, v in _CHANNEL_INDEX.items()} + indexed = [] + + i = 0 + while i < len(data) - 20: + if data[i] == 0x20 and data[i + 1] == 0x00 and data[i + 2] == 0x04: + ch_val = _struct.unpack_from(" list[Coordinate]: + """Request the current XYZ positions of all pipettor channels. + + Queries Pipettor.GetPositions (cmd=25). Returns one Coordinate per channel, + ordered by channel index (0=rearmost). + + Uses the typed PrepGetPositions command with ChannelXYZPositionParameters + response struct for reliable parsing across firmware versions. + + Returns: + List of Coordinate, one per channel. + """ + resp = await self.client.send_command( + PrepCmd.PrepGetPositions(dest=await self._require("pipettor")), + raise_on_error=False, + ) + if resp is None or not resp.positions: + return [] + + _CHANNEL_ENUM_TO_IDX = {v: k for k, v in _CHANNEL_INDEX.items()} + indexed = [] + for p in resp.positions: + ch_idx = _CHANNEL_ENUM_TO_IDX.get(p.channel) + if ch_idx is not None: + indexed.append((ch_idx, Coordinate(x=p.position_x, y=p.position_y, z=p.position_z))) + + indexed.sort(key=lambda pair: pair[0]) + return [coord for _, coord in indexed] + + async def request_x_pos_channel_n(self, channel_idx: int = 0) -> float: + """Request X position of pipettor channel n (in mm). + + Analogous to STARBackend.request_x_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return positions[channel_idx].x + + async def request_y_pos_channel_n(self, channel_idx: int) -> float: + """Request Y position of pipettor channel n (in mm). + + Analogous to STARBackend.request_y_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return positions[channel_idx].y + + async def request_z_pos_channel_n(self, channel_idx: int) -> float: + """Request Z position of pipettor channel n (in mm). + + Analogous to STARBackend.request_z_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return positions[channel_idx].z + + async def get_channels_y_positions(self) -> dict[int, float]: + """Request Y positions of all channels. + + Analogous to STARBackend.get_channels_y_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Y position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.y for i, coord in enumerate(positions)} + + async def get_channels_z_positions(self) -> dict[int, float]: + """Request Z positions of all channels. + + Analogous to STARBackend.get_channels_z_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Z position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.z for i, coord in enumerate(positions)} + + async def request_tip_bottom_z_position(self, channel_idx: int) -> float: + """Request the Z position of the tip bottom on the specified channel. + + GetPositions returns tip-adjusted Z when a tip is mounted — the reported Z + is the tip bottom position, not the channel head. Verified empirically: + channel at traverse (167.5mm) with 50uL NTR tip (extension 42.4mm) reports + Z=125.1mm = 167.5 - 42.4. + + Requires a tip to be mounted (verified via sleeve sensor). + + Analogous to STARBackend.request_tip_bottom_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Tip bottom Z position in mm. + + Raises: + RuntimeError: If no tip is present on the channel. + """ + tip_presence = await self.sense_tip_presence() + if channel_idx >= len(tip_presence) or not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + return await self.request_z_pos_channel_n(channel_idx) + + async def request_probe_z_position(self, channel_idx: int) -> float: + """Request the Z position of the channel probe/head (excluding tip). + + Since GetPositions returns tip-adjusted Z when a tip is mounted, this + method queries the firmware's held tip definition (GetTipDefinitionHeld, + Pipettor cmd=13) to get the tip length and adds it back. + + When no tip is mounted, returns the same value as request_z_pos_channel_n(). + + Analogous to STARBackend.request_probe_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Channel head Z position in mm (excluding tip). + """ + z = await self.request_z_pos_channel_n(channel_idx) + tip_presence = await self.sense_tip_presence() + if channel_idx < len(tip_presence) and tip_presence[channel_idx]: + # Query firmware for the held tip definition to get tip length + Cmd = type( + "_GetTipDefHeld", + (PrepCmd._PrepStatusQuery,), + {"command_id": 13, "__annotations__": {"dest": Address}}, + ) + raw = await self.client.send_command( + Cmd(dest=await self._require("pipettor")), + return_raw=True, + raise_on_error=False, + ) + if raw is not None: + import struct as _struct + data = raw[0] + # TipDefinition struct: default_values, id, volume(F32), length(F32), ... + # The second F32 is the tip extension length + f32_count = 0 + i = 0 + while i < len(data) - 7: + if data[i] == 0x28 and data[i + 1] == 0x00: + f32_count += 1 + if f32_count == 2: # second F32 = length + tip_length = _struct.unpack_from(" 0: + z += tip_length + break + i += 8 + else: + i += 1 + return z + # --------------------------------------------------------------------------- # Tip presence sensing # --------------------------------------------------------------------------- @@ -1689,7 +1948,7 @@ async def sense_tip_presence(self) -> list[bool]: node IDs assigned by the firmware on a given instrument. Returns: - List of bools, one per channel (index 0=rear, 1=front). True if tip detected. + List of bools, one per channel (index 0=rearmost). True if tip detected. """ import struct as _struct @@ -1782,7 +2041,7 @@ async def request_pip_channel_version(self, channel: int) -> Optional[str]: """Request the firmware version string for a pipettor channel. Args: - channel: Channel index (0=rear, 1=front). + channel: Channel index (0=rearmost). Analogous to STARBackend.request_pip_channel_version(). """ @@ -1794,7 +2053,7 @@ async def request_pip_channel_serial_number(self, channel: int) -> Optional[str] """Request the serial number for a pipettor channel. Args: - channel: Channel index (0=rear, 1=front). + channel: Channel index (0=rearmost). """ if channel >= len(self._channel_node_info_addrs): return None @@ -1974,7 +2233,7 @@ async def move_channels_to_safe_z(self, channels: Optional[List[int]] = None) -> no height parameter is sent. Args: - channels: Channel indices to move (0=rear, 1=front). None = all channels. + channels: Channel indices to move (0=rearmost). None = all channels. """ if channels is None: channels = list(range(self.num_channels)) @@ -2025,6 +2284,20 @@ async def move_to_position( if isinstance(z, list): assert len(z) == len(channels), "len(z) must equal len(use_channels)" + # Validate against per-channel movement bounds (cached from firmware at setup). + y_vals = y if isinstance(y, list) else [y] * len(channels) + z_vals = z if isinstance(z, list) else [z] * len(channels) + for i, (y_i, z_i) in enumerate(zip(y_vals, z_vals)): + ch = channels[i] + if ch < len(self._channel_bounds): + b = self._channel_bounds[ch] + if not b["x_min"] <= x <= b["x_max"]: + raise ValueError(f"x={x} outside channel {ch} range [{b['x_min']:.1f}, {b['x_max']:.1f}]") + if not b["y_min"] <= y_i <= b["y_max"]: + raise ValueError(f"y={y_i} outside channel {ch} range [{b['y_min']:.1f}, {b['y_max']:.1f}]") + if z_i > b["z_max"]: + raise ValueError(f"z={z_i} above channel {ch} maximum {b['z_max']:.1f}") + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] for i, ch in enumerate(channels): y_i = y if isinstance(y, (int, float)) else y[i] diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py index 85bf0d9ec9f..c0b0d3df710 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -470,6 +470,20 @@ def default(cls) -> AdcParameters: ) +@dataclass +class ChannelBoundsParameters: + """Per-channel movement bounds returned by PipettorService.GetChannelBounds.""" + + default_values: PaddedBool + channel: WEnum + x_min: F32 + x_max: F32 + y_min: F32 + y_max: F32 + z_min: F32 + z_max: F32 + + @dataclass class ChannelXYZPositionParameters: default_values: PaddedBool @@ -1275,6 +1289,22 @@ class PrepMoveToPositionViaLane(PrepCommand): move_parameters: Annotated[GantryMoveXYZParameters, Struct()] +@dataclass +class PrepGetPositions(PrepCommand): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + positions: Annotated[list[ChannelXYZPositionParameters], StructArray()] + + @dataclass class PrepMoveZUpToSafe(PrepCommand): """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" @@ -1725,6 +1755,22 @@ class Response: sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] +@dataclass +class PrepGetChannelBounds(PrepCommand): + """GetChannelBounds (cmd=10, dest=PipettorService). + + Returns per-channel movement bounds (x_min, x_max, y_min, y_max, z_min, z_max) + as a StructArray of ChannelBoundsParameters. + """ + + command_id = 10 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + @dataclass class PrepGetPresentChannels(_PrepStatusQuery): """GetPresentChannels (cmd=17, dest=MLPrepService). From 60242b9b33565056130a90025a5196ef3534380e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 17 Mar 2026 08:31:56 +0000 Subject: [PATCH 38/42] Add move_channel_x/y/z for STAR API compatibility Adds per-axis movement methods matching STARBackend. Each reads current position, then calls `move_to_position` keeping other axes unchanged. Bounds validation from Commit 3 applies automatically. --- .../backends/hamilton/prep_backend.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index 530ca304604..f95ec19d77a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1928,6 +1928,59 @@ async def request_probe_z_position(self, channel_idx: int) -> float: i += 1 return z + # --------------------------------------------------------------------------- + # Per-axis channel movement + # --------------------------------------------------------------------------- + + async def move_channel_x(self, channel_idx: int, x: float) -> None: + """Move the gantry X axis to a position (in mm). + + On the Prep, X is shared across all channels (single gantry). The channel_idx + parameter is accepted for STAR API compatibility but does not affect which + channel moves — all channels move together in X. + + Analogous to STARBackend.move_channel_x(). + + Args: + channel_idx: Channel index (0=rearmost). Used to read current Y/Z. + x: Target X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position(x, positions[channel_idx].y, positions[channel_idx].z, + use_channels=channel_idx) + + async def move_channel_y(self, channel_idx: int, y: float) -> None: + """Move a channel in the Y direction (in mm). + + Analogous to STARBackend.move_channel_y(). + + Args: + channel_idx: Channel index (0=rearmost). + y: Target Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position(positions[channel_idx].x, y, positions[channel_idx].z, + use_channels=channel_idx) + + async def move_channel_z(self, channel_idx: int, z: float) -> None: + """Move a channel in the Z direction (in mm). + + Analogous to STARBackend.move_channel_z(). + + Args: + channel_idx: Channel index (0=rearmost). + z: Target Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position(positions[channel_idx].x, positions[channel_idx].y, z, + use_channels=channel_idx) + # --------------------------------------------------------------------------- # Tip presence sensing # --------------------------------------------------------------------------- From 09c7fee62120fb64cf72e3db961f23813216357c Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 17 Mar 2026 08:40:03 +0000 Subject: [PATCH 39/42] Add cLLD and ztouch probing stubs with implementation notes --- .../backends/hamilton/prep_backend.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index f95ec19d77a..efc147bc9bc 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -2030,6 +2030,68 @@ async def sense_tip_presence(self) -> list[bool]: return results + # --------------------------------------------------------------------------- + # Capacitance-based probing (cLLD) + # --------------------------------------------------------------------------- + + async def clld_probe_x_position_using_channel(self, *args, **kwargs): + """Probe X position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:17] MoveChannelAxisAbsolute and + [1:18] MoveChannelAxisRelative for X-axis probing with cLLD feedback. + The ChannelCoordinator also has [1:19] YSeekLldPosition which may have + an X equivalent, though none was found in introspection. + """ + raise NotImplementedError("clld_probe_x_position_using_channel is not yet implemented for PrepBackend.") + + async def clld_probe_y_position_using_channel(self, *args, **kwargs): + """Probe Y position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:19] YSeekLldPosition(seekParameters) + which takes a YLLDSeekParameters struct and returns SeekResultParameters. + Also Channel [1:11] LeakCheck has ySeekDistance/yPreloadDistance params + which suggest Y-axis seeking capability. + """ + raise NotImplementedError("clld_probe_y_position_using_channel is not yet implemented for PrepBackend.") + + async def clld_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using capacitive LLD. Not yet implemented for the Prep. + + TODO: Implement using the standalone ZSeekLldPosition command: + - Pipettor [1:29] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + - ChannelCoordinator [1:20] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + Previously returned HC_RESULT=0x0F06 which was assumed to be "LLD not supported". + Now identified as "Z position out of allowed movement range" — the Z parameters + in LLDChannelSeekParameters were out of bounds. Retry with valid Z values + within deck_bounds (min_z=18.03, max_z=167.5). + + Findings from testing: + - cLLD DOES work through the aspirate path (aspirate with use_lld=True and + default_values=False on both LldParameters and CLldParameters). + - Standalone ZSeekLldPosition is rejected with 0x0F06 when Z params are out of range. + - The aspirate-based approach is a workaround, not a proper standalone probe. + + Also investigate ZAxis-level alternatives: + - ZAxis.SeekCapacitiveLld [1:12] (returns 0x0207 when called directly) + - ZAxis.SeekCapacitiveLldTip [1:13] (returns 0x0207 when called directly) + - ZAxis.LiquidStatus [1:16] for reading last detection results + - PipettorService.MeasureLldFrequency [1:6] for sensor health checks + """ + raise NotImplementedError("clld_probe_z_height_using_channel is not yet implemented for PrepBackend.") + + async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using force/motor stall detection. Not yet implemented for the Prep. + + TODO: Investigate force-based Z probing commands: + - ZAxis.SeekObstacle [1:14] SeekObstacle(startPosition, endPosition, finalPosition, velocity) + Currently returns 0x0207 when called directly — needs coordinator routing. + - Calibration.ZTouchoff [1:8] — runs a Z touchoff calibration (force-based). + - The STAR implements this via a dedicated "ZH" firmware command with PWM-based + force detection. The Prep may have an equivalent through the ChannelCoordinator + but it was not found in introspection. + """ + raise NotImplementedError("ztouch_probe_z_height_using_channel is not yet implemented for PrepBackend.") + # --------------------------------------------------------------------------- # Firmware version queries # --------------------------------------------------------------------------- From 6b31c8530f315c56f2142154c5d5b012bdf67053 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 17 Mar 2026 22:58:28 +0000 Subject: [PATCH 40/42] fix merge issue --- .../00_liquid-handling/_liquid-handling.rst | 1 - .../backends/hamilton/prep_backend.py | 278 +++++++++++++----- 2 files changed, 207 insertions(+), 72 deletions(-) diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index aa8ddc0708b..005fb12abbc 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -18,7 +18,6 @@ Examples: hamilton-vantage/_hamilton-vantage hamilton-prep/_hamilton-prep hamilton-nimbus/_hamilton-nimbus - opentrons-ot2/hello-world opentrons/ot2/ot2 tecan-evo/_tecan-evo plate-washing/plate-washing diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index efc147bc9bc..bd76b15b0ec 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import enum import logging import math import random @@ -182,6 +183,22 @@ class PrepBackend(LiquidHandlerBackend): On-demand introspection: ``await self.client.introspect(path)``. """ + class LLDMode(enum.Enum): + """Liquid level detection mode. + + Same numbering as STARBackend.LLDMode for cross-backend compatibility. + CAPACITIVE (value=1) is named GAMMA on the STAR — CAPACITIVE is the correct term. + The Prep firmware uses separate command variants for LLD vs no-LLD, so all + channels in a single aspirate/dispense call must use the same mode category + (any LLD mode, or OFF). Mixing OFF with CAPACITIVE/PRESSURE in one call is + not supported and will raise ValueError. + """ + + OFF = 0 + CAPACITIVE = 1 # STARBackend.LLDMode.GAMMA — capacitive (cLLD) + PRESSURE = 2 # pressure-based (pLLD) + DUAL = 3 # both capacitive and pressure + # Declare known object paths via InterfaceSpec. deck_config required (key positions, traverse height, deck info). _INTERFACES: dict[str, InterfaceSpec] = { "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), @@ -319,9 +336,6 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): # Resolve all interfaces (required fail-fast; optional log and continue) await self._resolver.run_setup_loop() - # Discover per-channel drive addresses from the object tree. - await self._discover_channel_drives() - if force_initialize: await self._run_initialize(smart=smart) logger.info("Prep initialization complete (force_initialize=True)") @@ -353,14 +367,19 @@ async def setup(self, smart: bool = True, force_initialize: bool = False): self._config.has_mph, ) - # Cache per-channel movement bounds from firmware (required for move validation) - self._channel_bounds = await self.request_channel_bounds() - if not self._channel_bounds: - raise RuntimeError( - "Failed to query channel movement bounds (GetChannelBounds). " - "Cannot validate movement commands without bounds." - ) - logger.info("Channel bounds: %s", self._channel_bounds) + # Discover per-channel drive addresses from the object tree (after init). + await self._discover_channel_drives() + + # Cache per-channel movement bounds from firmware + try: + self._channel_bounds = await self.request_channel_bounds() + except Exception as e: + logger.warning("Failed to query channel bounds: %s", e) + self._channel_bounds = [] + if self._channel_bounds: + logger.info("Channel bounds: %s", self._channel_bounds) + else: + logger.warning("Channel bounds not available — move_to_position will skip validation") self.setup_finished = True @@ -465,10 +484,17 @@ async def find_child_by_name(parent_addr, parent_info, name): else: logger.warning("Channel Root on node %d: could not find NodeInformation", sub_addr.node) - logger.debug("Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s, NodeInfo=%s", - sub_addr.node, sdrive_addr, zdrive_addr, node_info_addr) + logger.debug( + "Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s, NodeInfo=%s", + sub_addr.node, + sdrive_addr, + zdrive_addr, + node_info_addr, + ) - logger.info("Discovered %d pipettor channel drive pairs", len(self._channel_sleeve_sensor_addrs)) + logger.info( + "Discovered %d pipettor channel drive pairs", len(self._channel_sleeve_sensor_addrs) + ) async def _run_initialize(self, smart: bool): """Send PrepCmd.PrepInitialize to MLPrep (shared by setup).""" @@ -964,6 +990,7 @@ async def aspirate( z_minimum: Optional[List[float]] = None, z_bottom_search_offset: Optional[List[float]] = None, monitoring_mode: PrepCmd.MonitoringMode = PrepCmd.MonitoringMode.MONITORING, + lld_mode: Optional[List[LLDMode]] = None, use_lld: bool = False, lld: Optional[PrepCmd.LldParameters] = None, p_lld: Optional[PrepCmd.PLldParameters] = None, @@ -975,10 +1002,11 @@ async def aspirate( auto_container_geometry: bool = False, hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, ): """Aspirate using v2 commands, dispatching to the appropriate variant. - Selects the command variant based on ``use_lld`` / ``lld`` (LLD on/off) and + Selects the command variant based on ``lld_mode`` (LLD on/off) and ``monitoring_mode`` (Monitoring vs TADM). Z/geometry parameters (z_final, z_fluid, z_air, z_minimum, z_bottom_search_offset): None = use defaults for all channels (derived from well geometry, STAR-aligned). Otherwise pass a list of @@ -1001,25 +1029,24 @@ async def aspirate( z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). monitoring_mode: Select TADM or Monitoring (default: Monitoring). - use_lld: Enable LLD aspirate variant. Also activated if ``lld`` is set. - lld: LLD seek parameters. When None and use_lld=True, built from labware geometry - (z_seek = top of well; z_submerge/z_out_of_liquid = relative offsets). + lld_mode: Per-channel LLD mode list. Any non-OFF mode activates the LLD + command variant. All channels must use the same category (all LLD or all OFF). + use_lld: Enable LLD aspirate variant. Deprecated — use ``lld_mode`` instead. + lld: LLD seek parameters. When None and LLD active, built from labware geometry. p_lld: Pressure LLD parameters (LLD variants only). c_lld: Capacitive LLD parameters (LLD variants only). - tadm: TADM parameters (TADM variants only). Firmware defaults when None. - container_segments: Per-channel PrepCmd.SegmentDescriptor lists for liquid following. - If None and auto_container_geometry=True, derived from well geometry. - auto_container_geometry: Automatically build container segments from the - well's cross-section geometry. Pass False to use empty segments - (firmware falls back to the PrepCmd.CommonParameters cone model). - hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). - Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. - disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. + tadm: TADM parameters (TADM variants only). Firmware defaults when None. + container_segments: Per-channel SegmentDescriptor lists for liquid following. + auto_container_geometry: Build container segments from well geometry. + hamilton_liquid_classes: Per-op Hamilton liquid classes. None = auto from tip/liquid. + disable_volume_correction: Per-op flag to skip volume correction. + read_timeout: Override read timeout (seconds) for this command. When None, + auto-calculated from LLD seek distance/speed + 5s buffer. Example:: await backend.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) - await backend.aspirate(ops, [0], use_lld=True) + await backend.aspirate(ops, [0], lld_mode=[PrepBackend.LLDMode.CAPACITIVE]) await backend.aspirate(ops, [0], monitoring_mode=PrepCmd.MonitoringMode.TADM) """ assert len(ops) == len(use_channels) @@ -1088,7 +1115,23 @@ async def aspirate( for op, hlc in zip(ops, hlcs) ] - effective_lld = use_lld or (lld is not None) + # Resolve LLD mode: lld_mode list takes precedence, then use_lld bool, + # then lld parameter presence. The Prep firmware uses separate command variants + # for LLD vs no-LLD, so all channels must agree on LLD on/off. + if lld_mode is not None: + if len(lld_mode) != n: + raise ValueError(f"lld_mode length must match len(ops): {len(lld_mode)} != {n}") + lld_on = [m != self.LLDMode.OFF for m in lld_mode] + if any(lld_on) and not all(lld_on): + raise ValueError( + "Prep firmware requires all channels to use the same LLD mode category. " + "Cannot mix LLDMode.OFF with CAPACITIVE/PRESSURE/DUAL in one call. " + "Split into separate calls for channels with different LLD modes." + ) + effective_lld = all(lld_on) + else: + effective_lld = use_lld or (lld is not None) + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} # Precompute well geometry once (used for default Z lists and for LLD in the loop). @@ -1119,8 +1162,27 @@ async def aspirate( else: ch_segments[ch] = [] - _p_lld = p_lld or PrepCmd.PLldParameters.default() - _c_lld = c_lld or PrepCmd.CLldParameters.default() + # For LLD to actually trigger, both LldParameters and CLldParameters must use + # default_values=False. With default_values=True the firmware silently skips LLD. + # Empirically validated: sensitivity=4, detect_mode=0 triggers cLLD on the Prep. + if effective_lld: + _p_lld = p_lld or PrepCmd.PLldParameters( + default_values=False, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + _c_lld = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + _p_lld = p_lld or PrepCmd.PLldParameters.default() + _c_lld = c_lld or PrepCmd.CLldParameters.default() _tadm = tadm or PrepCmd.TadmParameters.default() params_lld_mon: List[PrepCmd.AspirateParametersLldAndMonitoring2] = [] @@ -1154,7 +1216,7 @@ async def aspirate( _lld = PrepCmd.LldParameters( default_values=False, z_seek=top_of_well_z, - z_seek_speed=0.0, + z_seek_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 z_submerge=2.0, z_out_of_liquid=0.0, ) @@ -1237,13 +1299,25 @@ async def aspirate( ) dest = await self._require("pipettor") + + # For LLD aspirates, auto-calculate read_timeout from seek distance and speed + # to prevent connection timeout during slow descents. + # Explicit read_timeout from caller takes precedence. + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and _lld.z_seek_speed > 0: + seek_distance = _lld.z_seek - min(z_minimum) + if seek_distance > 0: + lld_read_timeout = seek_distance / _lld.z_seek_speed + 5.0 + if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: await self.client.send_command( - PrepCmd.PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm) + PrepCmd.PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm), + read_timeout=lld_read_timeout, ) elif effective_lld: await self.client.send_command( - PrepCmd.PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon) + PrepCmd.PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon), + read_timeout=lld_read_timeout, ) elif monitoring_mode == PrepCmd.MonitoringMode.TADM: await self.client.send_command( @@ -1268,6 +1342,7 @@ async def dispense( cutoff_speed: Optional[List[float]] = None, z_minimum: Optional[List[float]] = None, z_bottom_search_offset: Optional[List[float]] = None, + lld_mode: Optional[List[LLDMode]] = None, use_lld: bool = False, lld: Optional[PrepCmd.LldParameters] = None, c_lld: Optional[PrepCmd.CLldParameters] = None, @@ -1299,19 +1374,20 @@ async def dispense( cutoff_speed: Cutoff/stop flow rate (µL/s) per channel. None = defaults for all; else list of len(ops). z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). - use_lld: Enable LLD dispense variant. Also activated if ``lld`` is set. - lld: LLD seek parameters. When None and use_lld=True, built from labware geometry. + lld_mode: Per-channel LLD mode list. Only CAPACITIVE or OFF supported for + dispense (pressure LLD is physically impossible during dispense). + use_lld: Enable LLD dispense variant. Deprecated — use ``lld_mode`` instead. + lld: LLD seek parameters. When None and LLD active, built from labware geometry. c_lld: Capacitive LLD parameters (LLD variant only). - container_segments: Per-channel PrepCmd.SegmentDescriptor lists for liquid following. - auto_container_geometry: Automatically build container segments from well geometry. - hamilton_liquid_classes: None = defaults per op via get_star_liquid_class (same as STAR). - Else list of Hamilton liquid classes, one per op; length must match len(ops), no None in list. - disable_volume_correction: Per-op flag to skip volume correction. When None, treated as [False]*n. + container_segments: Per-channel SegmentDescriptor lists for liquid following. + auto_container_geometry: Build container segments from well geometry. + hamilton_liquid_classes: Per-op Hamilton liquid classes. None = auto from tip/liquid. + disable_volume_correction: Per-op flag to skip volume correction. Example:: await backend.dispense(ops, [0], final_z=[95.0], settling_time=[0.5]) - await backend.dispense(ops, [0], use_lld=True) + await backend.dispense(ops, [0], lld_mode=[PrepBackend.LLDMode.CAPACITIVE]) """ assert len(ops) == len(use_channels) if use_channels: @@ -1379,7 +1455,28 @@ async def dispense( for op, hlc in zip(ops, hlcs) ] - effective_lld = use_lld or (lld is not None) + # Resolve LLD mode — same structure as aspirate(), but dispense only supports + # capacitive LLD (pressure LLD is physically impossible during dispense). + if lld_mode is not None: + if len(lld_mode) != n: + raise ValueError(f"lld_mode length must match len(ops): {len(lld_mode)} != {n}") + for m in lld_mode: + if m in (self.LLDMode.PRESSURE, self.LLDMode.DUAL): + raise ValueError( + f"Dispense does not support {m.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + lld_on = [m != self.LLDMode.OFF for m in lld_mode] + if any(lld_on) and not all(lld_on): + raise ValueError( + "Prep firmware requires all channels to use the same LLD mode category. " + "Cannot mix LLDMode.OFF with CAPACITIVE in one call. " + "Split into separate calls for channels with different LLD modes." + ) + effective_lld = all(lld_on) + else: + effective_lld = use_lld or (lld is not None) + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} # Precompute well geometry once (used for default Z lists and for LLD in the loop). @@ -1409,7 +1506,17 @@ async def dispense( else: ch_segments[ch] = [] - _c_lld = c_lld or PrepCmd.CLldParameters.default() + # See aspirate() comment — default_values=False required for LLD to trigger. + if effective_lld: + _c_lld = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + _c_lld = c_lld or PrepCmd.CLldParameters.default() params_nolld: List[PrepCmd.DispenseParametersNoLld2] = [] params_lld: List[PrepCmd.DispenseParametersLld2] = [] @@ -1439,7 +1546,7 @@ async def dispense( _lld = PrepCmd.LldParameters( default_values=False, z_seek=top_of_well_z, - z_seek_speed=0.0, + z_seek_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 z_submerge=2.0, z_out_of_liquid=0.0, ) @@ -1730,7 +1837,7 @@ async def request_channel_bounds(self) -> list[dict]: ch_idx = _CHANNEL_ENUM_TO_IDX.get(ch_val) j = i + 8 - floats = [] + floats: list[float] = [] while len(floats) < 6 and j < len(data) - 7: if data[j] == 0x28 and data[j + 1] == 0x00: floats.append(_struct.unpack_from(" list[dict]: j += 1 if ch_idx is not None and len(floats) == 6: - indexed.append((ch_idx, { - "x_min": floats[0], "x_max": floats[1], - "y_min": floats[2], "y_max": floats[3], - "z_min": floats[4], "z_max": floats[5], - })) + indexed.append( + ( + ch_idx, + { + "x_min": floats[0], + "x_max": floats[1], + "y_min": floats[2], + "y_max": floats[3], + "z_min": floats[4], + "z_max": floats[5], + }, + ) + ) i = j else: i += 1 @@ -1910,6 +2025,7 @@ async def request_probe_z_position(self, channel_idx: int) -> float: ) if raw is not None: import struct as _struct + data = raw[0] # TipDefinition struct: default_values, id, volume(F32), length(F32), ... # The second F32 is the tip extension length @@ -1948,8 +2064,9 @@ async def move_channel_x(self, channel_idx: int, x: float) -> None: positions = await self.request_channel_positions() if channel_idx >= len(positions): raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") - await self.move_to_position(x, positions[channel_idx].y, positions[channel_idx].z, - use_channels=channel_idx) + await self.move_to_position( + x, positions[channel_idx].y, positions[channel_idx].z, use_channels=channel_idx + ) async def move_channel_y(self, channel_idx: int, y: float) -> None: """Move a channel in the Y direction (in mm). @@ -1963,8 +2080,9 @@ async def move_channel_y(self, channel_idx: int, y: float) -> None: positions = await self.request_channel_positions() if channel_idx >= len(positions): raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") - await self.move_to_position(positions[channel_idx].x, y, positions[channel_idx].z, - use_channels=channel_idx) + await self.move_to_position( + positions[channel_idx].x, y, positions[channel_idx].z, use_channels=channel_idx + ) async def move_channel_z(self, channel_idx: int, z: float) -> None: """Move a channel in the Z direction (in mm). @@ -1978,8 +2096,9 @@ async def move_channel_z(self, channel_idx: int, z: float) -> None: positions = await self.request_channel_positions() if channel_idx >= len(positions): raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") - await self.move_to_position(positions[channel_idx].x, positions[channel_idx].y, z, - use_channels=channel_idx) + await self.move_to_position( + positions[channel_idx].x, positions[channel_idx].y, z, use_channels=channel_idx + ) # --------------------------------------------------------------------------- # Tip presence sensing @@ -2006,9 +2125,7 @@ async def sense_tip_presence(self) -> list[bool]: import struct as _struct if not self._channel_sleeve_sensor_addrs: - raise RuntimeError( - "No channel sleeve sensor addresses discovered. Call setup() first." - ) + raise RuntimeError("No channel sleeve sensor addresses discovered. Call setup() first.") results: list[bool] = [] for addr in self._channel_sleeve_sensor_addrs: @@ -2042,7 +2159,9 @@ async def clld_probe_x_position_using_channel(self, *args, **kwargs): The ChannelCoordinator also has [1:19] YSeekLldPosition which may have an X equivalent, though none was found in introspection. """ - raise NotImplementedError("clld_probe_x_position_using_channel is not yet implemented for PrepBackend.") + raise NotImplementedError( + "clld_probe_x_position_using_channel is not yet implemented for PrepBackend." + ) async def clld_probe_y_position_using_channel(self, *args, **kwargs): """Probe Y position using capacitive LLD. Not yet implemented for the Prep. @@ -2052,7 +2171,9 @@ async def clld_probe_y_position_using_channel(self, *args, **kwargs): Also Channel [1:11] LeakCheck has ySeekDistance/yPreloadDistance params which suggest Y-axis seeking capability. """ - raise NotImplementedError("clld_probe_y_position_using_channel is not yet implemented for PrepBackend.") + raise NotImplementedError( + "clld_probe_y_position_using_channel is not yet implemented for PrepBackend." + ) async def clld_probe_z_height_using_channel(self, *args, **kwargs): """Probe Z-height using capacitive LLD. Not yet implemented for the Prep. @@ -2077,7 +2198,9 @@ async def clld_probe_z_height_using_channel(self, *args, **kwargs): - ZAxis.LiquidStatus [1:16] for reading last detection results - PipettorService.MeasureLldFrequency [1:6] for sensor health checks """ - raise NotImplementedError("clld_probe_z_height_using_channel is not yet implemented for PrepBackend.") + raise NotImplementedError( + "clld_probe_z_height_using_channel is not yet implemented for PrepBackend." + ) async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): """Probe Z-height using force/motor stall detection. Not yet implemented for the Prep. @@ -2090,7 +2213,9 @@ async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): force detection. The Prep may have an equivalent through the ChannelCoordinator but it was not found in introspection. """ - raise NotImplementedError("ztouch_probe_z_height_using_channel is not yet implemented for PrepBackend.") + raise NotImplementedError( + "ztouch_probe_z_height_using_channel is not yet implemented for PrepBackend." + ) # --------------------------------------------------------------------------- # Firmware version queries @@ -2105,7 +2230,7 @@ def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: """ if raw is None: return None - data = raw[0] + data: bytes = raw[0] i = 0 while i < len(data) - 3: if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): @@ -2115,14 +2240,18 @@ def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: i += 1 return None - async def _query_firmware_string(self, addr: Address, cmd_id: int, iface_id: int = 3) -> Optional[str]: + async def _query_firmware_string( + self, addr: Address, cmd_id: int, iface_id: int = 3 + ) -> Optional[str]: """Send a status query and decode the string response.""" Cmd = type( "_FWQuery", (PrepCmd._PrepStatusQuery,), {"command_id": cmd_id, "interface_id": iface_id, "__annotations__": {"dest": Address}}, ) - raw = await self.client.send_command(Cmd(dest=addr), return_raw=True, raise_on_error=False) + raw: Optional[tuple] = await self.client.send_command( + Cmd(dest=addr), return_raw=True, raise_on_error=False + ) return self._decode_firmware_string(raw) async def request_firmware_version(self) -> Optional[str]: @@ -2162,7 +2291,9 @@ async def request_pip_channel_version(self, channel: int) -> Optional[str]: """ if channel >= len(self._channel_node_info_addrs): return None - return await self._query_firmware_string(self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1) + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1 + ) async def request_pip_channel_serial_number(self, channel: int) -> Optional[str]: """Request the serial number for a pipettor channel. @@ -2172,7 +2303,9 @@ async def request_pip_channel_serial_number(self, channel: int) -> Optional[str] """ if channel >= len(self._channel_node_info_addrs): return None - return await self._query_firmware_string(self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1) + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1 + ) async def request_module_version(self) -> Optional[str]: """Request the pipettor module version string (from PipettorRoot.ModuleInformation).""" @@ -2201,7 +2334,8 @@ async def print_firmware_tree(self) -> None: intro = HamiltonIntrospection(self.client) root_addrs = self.client._registry.get_root_addresses() if not root_addrs: - return "(no root objects discovered)" + print("(no root objects discovered)") + return lines: list[str] = [] @@ -2409,7 +2543,9 @@ async def move_to_position( if not b["x_min"] <= x <= b["x_max"]: raise ValueError(f"x={x} outside channel {ch} range [{b['x_min']:.1f}, {b['x_max']:.1f}]") if not b["y_min"] <= y_i <= b["y_max"]: - raise ValueError(f"y={y_i} outside channel {ch} range [{b['y_min']:.1f}, {b['y_max']:.1f}]") + raise ValueError( + f"y={y_i} outside channel {ch} range [{b['y_min']:.1f}, {b['y_max']:.1f}]" + ) if z_i > b["z_max"]: raise ValueError(f"z={z_i} above channel {ch} maximum {b['z_max']:.1f}") From 4fa281a1f29749e67cc43eb07aee79c83899c147 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 18 Mar 2026 00:03:58 +0000 Subject: [PATCH 41/42] Reorder new PrepBackend sections by functionality level --- .../backends/hamilton/prep_backend.py | 204 +++++++++--------- 1 file changed, 102 insertions(+), 102 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index bd76b15b0ec..a265cce508d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1786,6 +1786,108 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: return False return True + # --------------------------------------------------------------------------- + # Firmware version queries + # --------------------------------------------------------------------------- + + @staticmethod + def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: + """Decode a string from a raw HOI response. + + Hamilton string wire format: 0x0F + type_byte + u16 length + chars. + type_byte is 0x00 (plain) or 0x01 (with null terminator); both are handled. + """ + if raw is None: + return None + data: bytes = raw[0] + i = 0 + while i < len(data) - 3: + if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): + slen = int.from_bytes(data[i + 2 : i + 4], "little") + if slen > 0 and i + 4 + slen <= len(data): + return data[i + 4 : i + 4 + slen].decode("utf-8", errors="replace").rstrip("\x00") + i += 1 + return None + + async def _query_firmware_string( + self, addr: Address, cmd_id: int, iface_id: int = 3 + ) -> Optional[str]: + """Send a status query and decode the string response.""" + Cmd = type( + "_FWQuery", + (PrepCmd._PrepStatusQuery,), + {"command_id": cmd_id, "interface_id": iface_id, "__annotations__": {"dest": Address}}, + ) + raw: Optional[tuple] = await self.client.send_command( + Cmd(dest=addr), return_raw=True, raise_on_error=False + ) + return self._decode_firmware_string(raw) + + async def request_firmware_version(self) -> Optional[str]: + """Request the instrument controller firmware version string. + + Returns a string like "MLPrep Runtime V1.2.2.444 99020-02 Rev G", + or None if MLPrepCpu was not discovered. + + Analogous to STARBackend.request_firmware_version(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=8) + + async def request_device_serial_number(self) -> Optional[str]: + """Request the instrument serial number. + + Analogous to STARBackend.request_device_serial_number(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=9) + + async def request_bootloader_version(self) -> Optional[str]: + """Request the instrument bootloader version string.""" + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=2, iface_id=2) + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Request the firmware version string for a pipettor channel. + + Args: + channel: Channel index (0=rearmost). + + Analogous to STARBackend.request_pip_channel_version(). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1 + ) + + async def request_pip_channel_serial_number(self, channel: int) -> Optional[str]: + """Request the serial number for a pipettor channel. + + Args: + channel: Channel index (0=rearmost). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1 + ) + + async def request_module_version(self) -> Optional[str]: + """Request the pipettor module version string (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=8) + + async def request_module_part_number(self) -> Optional[str]: + """Request the firmware part number (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=5) + # --------------------------------------------------------------------------- # Channel position queries # --------------------------------------------------------------------------- @@ -2217,108 +2319,6 @@ async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): "ztouch_probe_z_height_using_channel is not yet implemented for PrepBackend." ) - # --------------------------------------------------------------------------- - # Firmware version queries - # --------------------------------------------------------------------------- - - @staticmethod - def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: - """Decode a string from a raw HOI response. - - Hamilton string wire format: 0x0F + type_byte + u16 length + chars. - type_byte is 0x00 (plain) or 0x01 (with null terminator); both are handled. - """ - if raw is None: - return None - data: bytes = raw[0] - i = 0 - while i < len(data) - 3: - if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): - slen = int.from_bytes(data[i + 2 : i + 4], "little") - if slen > 0 and i + 4 + slen <= len(data): - return data[i + 4 : i + 4 + slen].decode("utf-8", errors="replace").rstrip("\x00") - i += 1 - return None - - async def _query_firmware_string( - self, addr: Address, cmd_id: int, iface_id: int = 3 - ) -> Optional[str]: - """Send a status query and decode the string response.""" - Cmd = type( - "_FWQuery", - (PrepCmd._PrepStatusQuery,), - {"command_id": cmd_id, "interface_id": iface_id, "__annotations__": {"dest": Address}}, - ) - raw: Optional[tuple] = await self.client.send_command( - Cmd(dest=addr), return_raw=True, raise_on_error=False - ) - return self._decode_firmware_string(raw) - - async def request_firmware_version(self) -> Optional[str]: - """Request the instrument controller firmware version string. - - Returns a string like "MLPrep Runtime V1.2.2.444 99020-02 Rev G", - or None if MLPrepCpu was not discovered. - - Analogous to STARBackend.request_firmware_version(). - """ - if self._mlprep_cpu_addr is None: - return None - return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=8) - - async def request_device_serial_number(self) -> Optional[str]: - """Request the instrument serial number. - - Analogous to STARBackend.request_device_serial_number(). - """ - if self._mlprep_cpu_addr is None: - return None - return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=9) - - async def request_bootloader_version(self) -> Optional[str]: - """Request the instrument bootloader version string.""" - if self._mlprep_cpu_addr is None: - return None - return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=2, iface_id=2) - - async def request_pip_channel_version(self, channel: int) -> Optional[str]: - """Request the firmware version string for a pipettor channel. - - Args: - channel: Channel index (0=rearmost). - - Analogous to STARBackend.request_pip_channel_version(). - """ - if channel >= len(self._channel_node_info_addrs): - return None - return await self._query_firmware_string( - self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1 - ) - - async def request_pip_channel_serial_number(self, channel: int) -> Optional[str]: - """Request the serial number for a pipettor channel. - - Args: - channel: Channel index (0=rearmost). - """ - if channel >= len(self._channel_node_info_addrs): - return None - return await self._query_firmware_string( - self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1 - ) - - async def request_module_version(self) -> Optional[str]: - """Request the pipettor module version string (from PipettorRoot.ModuleInformation).""" - if self._module_info_addr is None: - return None - return await self._query_firmware_string(self._module_info_addr, cmd_id=8) - - async def request_module_part_number(self) -> Optional[str]: - """Request the firmware part number (from PipettorRoot.ModuleInformation).""" - if self._module_info_addr is None: - return None - return await self._query_firmware_string(self._module_info_addr, cmd_id=5) - # --------------------------------------------------------------------------- # Object tree inspection # --------------------------------------------------------------------------- From c17684fbf14210b28004ff779bdb426d637ba621 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 18 Mar 2026 00:09:27 +0000 Subject: [PATCH 42/42] Rename LldParameters fields to match firmware semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit z_seek → search_start_position, z_seek_speed → channel_speed. Disambiguates from tip pickup z_seek and clarifies that channel_speed is the Z-drive speed (vs pLLD's dispenser_seek_speed). --- .../backends/hamilton/prep_backend.py | 14 +++++++------- .../backends/hamilton/prep_backend_tests.py | 10 +++++----- .../backends/hamilton/prep_commands.py | 10 +++++++--- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py index a265cce508d..4dda366cfc5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -1215,8 +1215,8 @@ async def aspirate( top_of_well_z = well_geometry[idx][2] _lld = PrepCmd.LldParameters( default_values=False, - z_seek=top_of_well_z, - z_seek_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 + search_start_position=top_of_well_z, + channel_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 z_submerge=2.0, z_out_of_liquid=0.0, ) @@ -1304,10 +1304,10 @@ async def aspirate( # to prevent connection timeout during slow descents. # Explicit read_timeout from caller takes precedence. lld_read_timeout = read_timeout - if lld_read_timeout is None and effective_lld and _lld.z_seek_speed > 0: - seek_distance = _lld.z_seek - min(z_minimum) + if lld_read_timeout is None and effective_lld and _lld.channel_speed > 0: + seek_distance = _lld.search_start_position - min(z_minimum) if seek_distance > 0: - lld_read_timeout = seek_distance / _lld.z_seek_speed + 5.0 + lld_read_timeout = seek_distance / _lld.channel_speed + 5.0 if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: await self.client.send_command( @@ -1545,8 +1545,8 @@ async def dispense( top_of_well_z = well_geometry[idx][2] _lld = PrepCmd.LldParameters( default_values=False, - z_seek=top_of_well_z, - z_seek_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 + search_start_position=top_of_well_z, + channel_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 z_submerge=2.0, z_out_of_liquid=0.0, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py index c28568fe2c4..9f4a82a2eda 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py @@ -666,8 +666,8 @@ async def test_aspirate_implicit_lld_via_lld_param(self): """Passing lld= activates LLD path without use_lld=True.""" custom_lld = PrepCmd.LldParameters( default_values=False, - z_seek=90.0, - z_seek_speed=5.0, + search_start_position=90.0, + channel_speed=5.0, z_submerge=2.0, z_out_of_liquid=1.0, ) @@ -680,17 +680,17 @@ async def test_aspirate_implicit_lld_via_lld_param(self): self.assertEqual(len(cmds), 1) # Verify the provided LLD parameters are used (not auto-derived) lld = cmds[0].aspirate_parameters[0].lld - self.assertAlmostEqual(lld.z_seek, 90.0) + self.assertAlmostEqual(lld.search_start_position, 90.0) async def test_aspirate_lld_auto_seek_z(self): - """Auto-derived LLD z_seek equals the top-of-well Z.""" + """Auto-derived LLD search_start_position equals the top-of-well Z.""" self.plate.get_item("A1") op = self._make_asp() _, _, top_of_well_z, _ = _absolute_z_from_well(op) await self.backend.aspirate([op], use_channels=[0], use_lld=True) cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2)[0] lld = cmd.aspirate_parameters[0].lld - self.assertAlmostEqual(lld.z_seek, top_of_well_z, places=3) + self.assertAlmostEqual(lld.search_start_position, top_of_well_z, places=3) # --- Channel mapping --- diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py index c0b0d3df710..276979fccf6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -345,15 +345,19 @@ def for_fixed_z( @dataclass class LldParameters: default_values: PaddedBool - z_seek: F32 - z_seek_speed: F32 + search_start_position: F32 + channel_speed: F32 z_submerge: F32 z_out_of_liquid: F32 @classmethod def default(cls) -> LldParameters: return cls( - default_values=True, z_seek=0.0, z_seek_speed=0.0, z_submerge=0.0, z_out_of_liquid=0.0 + default_values=True, + search_start_position=0.0, + channel_speed=0.0, + z_submerge=0.0, + z_out_of_liquid=0.0, )