From fe3c00abd069d8f77462e9e9360e93a4bfe675f6 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:43:56 -0700 Subject: [PATCH 01/13] feat(hamilton): port full TCP transport client onto transport/tcp Replace the thin post-#1000 stub with the HOI/HARP session client, command layer, wire types, and introspection stack so Prep/Nimbus can build on it. --- pylabrobot/hamilton/transport/tcp/__init__.py | 33 +- pylabrobot/hamilton/transport/tcp/commands.py | 158 +- .../hamilton/transport/tcp/error_tables.py | 2401 +++++++++++++++++ .../hamilton/transport/tcp/hoi_error.py | 208 ++ .../transport/tcp/interface_bundle.py | 70 + .../hamilton/transport/tcp/introspection.py | 2113 ++++++++++++--- pylabrobot/hamilton/transport/tcp/messages.py | 717 ++--- pylabrobot/hamilton/transport/tcp/packets.py | 18 +- pylabrobot/hamilton/transport/tcp/protocol.py | 50 +- pylabrobot/hamilton/transport/tcp/tcp.py | 783 +++--- .../hamilton/transport/tcp/tests/tcp_tests.py | 966 +++++++ .../hamilton/transport/tcp/wire_types.py | 393 +++ 12 files changed, 6692 insertions(+), 1218 deletions(-) create mode 100644 pylabrobot/hamilton/transport/tcp/error_tables.py create mode 100644 pylabrobot/hamilton/transport/tcp/hoi_error.py create mode 100644 pylabrobot/hamilton/transport/tcp/interface_bundle.py create mode 100644 pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py create mode 100644 pylabrobot/hamilton/transport/tcp/wire_types.py diff --git a/pylabrobot/hamilton/transport/tcp/__init__.py b/pylabrobot/hamilton/transport/tcp/__init__.py index 78f60160c79..ad99ec6a6c7 100644 --- a/pylabrobot/hamilton/transport/tcp/__init__.py +++ b/pylabrobot/hamilton/transport/tcp/__init__.py @@ -1,24 +1,13 @@ """Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand -from pylabrobot.hamilton.transport.tcp.introspection import HamiltonIntrospection -from pylabrobot.hamilton.transport.tcp.messages import ( - CommandMessage, - CommandResponse, - HoiParams, - HoiParamsParser, - InitMessage, - InitResponse, - RegistrationMessage, - RegistrationResponse, -) -from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket -from pylabrobot.hamilton.transport.tcp.protocol import ( - HamiltonDataType, - HamiltonProtocol, - HarpTransportableProtocol, - Hoi2Action, - HoiRequestId, - RegistrationActionCode, - RegistrationOptionType, -) +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient + +__all__ = [ + "Address", + "HamiltonTCPClient", + "HoiError", + "TCPCommand", +] diff --git a/pylabrobot/hamilton/transport/tcp/commands.py b/pylabrobot/hamilton/transport/tcp/commands.py index 0b8cd8c025c..953659120ff 100644 --- a/pylabrobot/hamilton/transport/tcp/commands.py +++ b/pylabrobot/hamilton/transport/tcp/commands.py @@ -1,50 +1,50 @@ -"""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. +TCPCommand 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 dataclasses import fields, is_dataclass +from typing import Any, Optional from pylabrobot.hamilton.transport.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, + interpret_hoi_success_payload, + log_hoi_result_entries, + split_hoi_params_after_warning_prefix, ) from pylabrobot.hamilton.transport.tcp.packets import Address from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry -class HamiltonCommand: - """Base class for Hamilton commands using new simplified architecture. +class TCPCommand: + """Base class for Hamilton TCP commands. - This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: - - Explicitly uses CommandMessage for building packets - - build_parameters() returns HoiParams object (not bytes) - - Uses Address instead of ObjectAddress - - Cleaner separation of concerns + Preferred usage: define commands as ``@dataclass`` subclasses with + ``Annotated`` wire-type fields. ``build_parameters()`` and + ``interpret_response()`` are handled automatically by the base class. - Example: - class MyCommand(HamiltonCommand): + Example:: + + @dataclass + class MyCommand(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 command_id = 42 - def __init__(self, dest: Address, value: int): - super().__init__(dest) - self.value = value - - def build_parameters(self) -> HoiParams: - return HoiParams().i32(self.value) + dest: Address # infrastructure field — not serialised + value: Annotated[int, I32] # wire field — serialised in order - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - parser = HoiParamsParser(data) - _, result = parser.parse_next() - return {'result': result} + @dataclass + class Response: + result: Annotated[int, U32] """ # Class-level attributes that subclasses must override @@ -58,7 +58,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: ip_protocol: int = 6 # Default: OBJECT_DISCOVERY def __init__(self, dest: Address): - """Initialize Hamilton command. + """Initialize TCP command. Args: dest: Destination address for this command @@ -78,12 +78,17 @@ def __init__(self, dest: Address): def build_parameters(self) -> HoiParams: """Build HOI parameters for this command. - Override this method in subclasses to provide command-specific parameters. - Return a HoiParams object (not bytes!). + Default: serializes all ``Annotated`` wire-type fields on ``self`` via + ``HoiParams.from_struct``. On non-dataclass subclasses ``from_struct`` + finds no fields and returns an empty ``HoiParams``, preserving the old + behaviour. Override only when the wire layout cannot be expressed with + ``Annotated`` field declarations. Returns: HoiParams object with command parameters """ + if is_dataclass(self): + return HoiParams.from_struct(self) return HoiParams() def get_log_params(self) -> dict: @@ -150,19 +155,84 @@ 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 _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map a ``HcResultEntry`` to a 0-indexed PLR channel, or ``None`` to skip. - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. + Default: the entry's position in the HoiResult — firmware populates arrays + in active-channel order. ``NimbusCommand`` / ``PrepCommand`` override this + to translate the active-channel ordinal into the caller's 0-indexed channel + via ``channels_involved`` bitmask or per-channel struct-array reflection. + """ + return entry_index - Args: - response: CommandResponse from network + def error_entries_use_physical_channels(self) -> bool: + """Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices. - Returns: - Dictionary with parsed response data, or None if no data to extract + Returns ``True`` when the command carries per-channel wire parameters: + Prep ``StructArray`` elements with a ``channel`` field, or Nimbus + ``channels_involved`` parallel arrays. Void MLPrep / status queries return + ``False`` so the client raises :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError` + instead of attributing errors to synthetic ``ch0``. """ - return self.parse_response_parameters(response.hoi.params) + if not is_dataclass(self): + return False + for f in fields(self): + if f.name == "channels_involved": + return True + value = getattr(self, f.name, None) + if isinstance(value, list) and value: + if getattr(value[0], "channel", None) is not None: + return True + return False + + def interpret_response(self, response: CommandResponse) -> Any: + """Pure decoder for a success response — never raises on channel errors. + + For ``STATUS_WARNING`` / ``COMMAND_WARNING`` frames, strips the leading + summary + formatted-string prefix (per ``SystemController.SendAndReceive``) + and logs entries parsed via ``HoiDecoder2.GetHcResults``. For plain + ``STATUS_RESPONSE`` / ``COMMAND_RESPONSE`` frames, decodes the Response + dataclass directly — the firmware emits exactly the fields declared in + the interface yaml, with no HoiResult trailer. HoiResult only rides on + warning (prefix) or exception (separate payload, handled in + ``send_command``) frames. + + Fatal (non-success, non-warning) entries from a warning frame surface + through ``fatal_entries_by_channel`` and are lifted into a + ``ChannelizedError`` by the backend — this decoder stays pure. + """ + eff, _prefix = self._strip_warning_prefix(response) + return interpret_hoi_success_payload(self, eff) + + def fatal_entries_by_channel(self, response: CommandResponse) -> dict[int, HcResultEntry]: + """Return fatal entries keyed by 0-indexed PLR channel. + + Only non-success, non-warning entries from a warning-frame prefix are + included; warnings remain log-only. Exception frames are handled + separately in ``send_command`` via :func:`~pylabrobot.hamilton.transport.tcp.hoi_error.parse_hamilton_error_entry`. + + ``entry_index`` passed to ``_channel_index_for_entry`` is the position of + the entry in the *original* entries list (i.e. active-channel ordinal), + not among fatal entries only — so bitmask / struct-array overrides can + map ordinal → channel correctly even when earlier channels warned. + """ + _eff, prefix_entries = self._strip_warning_prefix(response) + per_channel: dict[int, HcResultEntry] = {} + for i, entry in enumerate(prefix_entries): + if entry.is_success: + continue + ch = self._channel_index_for_entry(i, entry) + if ch is None: + continue + per_channel[ch] = entry + return per_channel + + def _strip_warning_prefix(self, response: CommandResponse) -> tuple[bytes, list[HcResultEntry]]: + """Strip the warning-frame HoiResult prefix, if present. Logs entries.""" + raw = response.hoi.params + eff, prefix_entries = split_hoi_params_after_warning_prefix(response.hoi.action_code, raw) + log_hoi_result_entries(type(self).__name__, prefix_entries, source="HOI prefix") + return eff, prefix_entries @classmethod def parse_response_parameters(cls, data: bytes) -> Optional[dict]: @@ -177,3 +247,19 @@ def parse_response_parameters(cls, data: bytes) -> Optional[dict]: Dictionary with parsed response data, or None if no data to extract """ return None + + +def hamilton_error_for_entry(entry: HcResultEntry, description: str) -> Exception: + """Wrap an ``HcResultEntry`` in a ``RuntimeError`` using a pre-resolved description. + + ``description`` is sourced from the device itself via Interface 0 method 5 + (``EnumInfo``) — see ``HamiltonTCPClient._describe_entry``. The returned + exception has ``.entry`` attached so callers can dispatch on + ``entry.result`` / ``entry.interface_id`` / ``entry.address``. + """ + err = RuntimeError( + f"{description} (HcResult=0x{entry.result:04X}) " + f"at {entry.address} iface={entry.interface_id} action={entry.action_id}" + ) + err.entry = entry # type: ignore[attr-defined] + return err diff --git a/pylabrobot/hamilton/transport/tcp/error_tables.py b/pylabrobot/hamilton/transport/tcp/error_tables.py new file mode 100644 index 00000000000..f7a50da856d --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/error_tables.py @@ -0,0 +1,2401 @@ +"""Hamilton error-code tables. + +Generated by ``_generate_error_tables.py`` from firmware reference exports. +Do not edit by hand — regenerate with:: + + python -m pylabrobot.hamilton.transport.tcp._generate_error_tables + +Tables +------ +- ``HC_RESULT_PROTOCOL`` : ``{code: enum_name}``. Protocol-level universal + result codes that apply to any module. ~200 entries in the range 0–1069. +- ``NIMBUS_ERROR_CODES`` : ``{(module_id, node_id, object_id, interface_id, code): + text}``. Module-scoped text registered by ``NimbusCORESystem`` and + ``GripperControllerSystem`` ``AddErrorData`` calls at runtime. Codes in this + table start at 0x0F01 (3841) — the module-specific range. +- ``PREP_ERROR_CODES`` : Prep-specific codes (pipettor and MPH). Module-specific + range starting at 0x0F01. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +HC_RESULT_PROTOCOL: Dict[int, str] = { + 0: "Success", + 1: "GenericError", + 2: "GenericNotReady", + 3: "GenericNullParameter", + 4: "GenericCalledByInitHandler", + 5: "GenericInvalidData", + 6: "GenericOutOfMemory", + 7: "GenericWriteFault", + 8: "GenericReadFault", + 9: "GenericBufferOverflow", + 10: "GenericNotInitialized", + 11: "GenericAlreadyInitialized", + 12: "GenericWaitAborted", + 13: "GenericTimeOut", + 14: "GenericMissingCallBack", + 15: "GenericInvalidHandle", + 16: "GenericNotSupported", + 17: "GenericInvalidParameter", + 18: "GenericNotImplemented", + 19: "GenericBadCrc", + 20: "GenericFlashNotBlank", + 21: "GenericMultipleErrorsReported", + 22: "GenericCoordinatedCommandTimeout", + 23: "GenericAccessDenied", + 25: "GenericBusy", + 26: "GenericMethodObsolete", + 27: "GenericNotConfigured", + 257: "KernelMutexTimeout", + 258: "KernelSemaphoreTimeout", + 259: "KernelEventTimeout", + 260: "KernelNoMutex", + 261: "KernelMutexNotOwned", + 262: "KernelNoWaitingTask", + 263: "KernelInvalidTask", + 264: "KernelNoTaskControlBlock", + 513: "NetworkUndefinedProtocol", + 514: "NetworkNoDestination", + 515: "NetworkRegistrationError", + 516: "NetworkNotRegistered", + 517: "NetworkBusy", + 518: "NetworkInvalidDispatchID", + 519: "NetworkInvalidMessage", + 520: "NetworkUnsupportedParameter", + 521: "NetworkCommandCompleteNotValid", + 522: "NetworkInvalidMessageParameter", + 523: "NetworkIncompatibleProtocolVersion", + 524: "NetworkInvalidNodeId", + 525: "NetworkInvalidModuleId", + 526: "NetworkInvalidInterfaceId", + 527: "NetworkInvalidAction", + 528: "NetworkProxySendAttemptFailed", + 529: "NetworkRegistrationFailedDuplicateAddress", + 530: "NetworkUnableToProperlyFillOutResults", + 531: "NetworkDuplicateEventRegistration", + 532: "NetworkEventRegistrationExceedsMaximumAllowedSubscribers", + 533: "NetworkMaximumNodeToNodeEventRegistrationsExceeded", + 534: "NetworkMaximumNodeToNodeEventHandlerRegistrationsExceeded", + 535: "NetworkUnsupportedHarpPayloadProtocol", + 769: "XPortSlOsPortNotInstalled", + 770: "XPortSlIpTaskPriorityNotSet", + 771: "XPortSlTimerTaskPriorityNotSet", + 772: "XPortSlDriverNotSet", + 773: "XPortSlIpAddressNotSet", + 774: "XPortSlNetMaskNotSet", + 775: "XPortSlCmxInitFailure", + 776: "XPortSlMacAddressNotSet", + 777: "XPortSlHostNameTooShort", + 778: "XPortSlNostNameTooLong", + 779: "XPortSlHostNameInvalidChars", + 800: "XPortNxpLpc2xxxCanInvalidChannel", + 801: "XPortNxpLpc2xxxCanInvalidGroup", + 802: "XPortNxpLpc2xxxCanBitRate", + 803: "XPortNxpLpc2xxxCanRxInterruptInstall", + 804: "XPortNxpLpc2xxxCanRxInterruptRemove", + 805: "XPortNxpLpc2xxxCanTxInterruptInstall", + 806: "XPortNxpLpc2xxxCanTxInterruptRemove", + 807: "XPortNxpLpc2xxxCanTxInvalidLength", + 808: "XPortNxpLpc2xxxCanTxBusy", + 809: "XPortArcNetAlreadyConfigured", + 810: "XPortArcNetNotConfigured", + 811: "XPortArcNetInterruptInstallFailed", + 812: "XPortArcNetTxNoAck", + 813: "XPortArcNetDiagnosticTestFailed", + 814: "XPortArcNetNodeIdTestFailed", + 815: "XPortArcNetInvalidNodeId", + 816: "XPortArcNetTxNotAvailable", + 817: "XPortArcNetInvalidDataRate", + 818: "XPortArcNetInvalidPacketLength", + 819: "XPortArcNetSingleNodeNetwork", + 820: "XPortArcNetNoResponseToFbe", + 833: "XPortProtocolMismatch", + 834: "XPortPacketRouterNotRegistered", + 835: "XPortCouldNotStartPacketRouterRxThread", + 836: "XPortPacketRouterAlreadyRegistered", + 837: "XPortNoPacketToProcess", + 838: "XPortWireProtocolNotRegistered", + 839: "XPortWireProtocolAlreadyRegistered", + 840: "XPortWireProtocolRegistrationSpaceFull", + 841: "XPortPayloadProtocolNotRegistered", + 842: "XPortPayloadProtocolAlreadyRegistered", + 843: "XPortPayloadRegistrationSpaceFull", + 844: "XPortAddressNotSet", + 845: "XPortAttemptToSendToSelf", + 846: "XPortTxTimeout", + 847: "XPortRxDuplicateFrame", + 864: "XPortCanWp0VersionConflict", + 865: "XPortCanExcessivePacketSize", + 866: "XPortCanWp0AckHasNoMatchingPacket", + 867: "XPortCanWp0WrapperOnlyOneAddressSupported", + 868: "XPortCanWp0ErrorStartRefused", + 869: "XPortCanWp0ErrorBufferOverrun", + 870: "XPortCanWp0InvalidFrame", + 871: "XPortCanWp0StrayDataFrame", + 872: "XPortCanWp0ShortMessage", + 873: "XPortCanWp0LongMessage", + 874: "XPortCanWp0UnknownError", + 875: "XPortCanWp0NoResponseFromDestination", + 876: "XPortCanWp0SendError", + 877: "XPortCanWbzUnknownFrame", + 878: "XPortCanWbzUnsolicitedRemoteFrame", + 879: "XPortCanWbzUnsolicitedDataFrame", + 880: "XPortCanWbzWrapperOnlyOneAddressSupported", + 881: "XPortCanWp0LastMessageFailed", + 896: "XPortIpStackConfigurationFailure", + 897: "XPortIpStackNotConfigured", + 898: "XPortSocketCreationFailure", + 899: "XPortSocketConfigFailure", + 900: "XPortSocketBindFailure", + 901: "XPortIpTaskAlreadyStarted", + 902: "XPortIpTaskNotStarted", + 903: "XPortTcpListenFailure", + 904: "XPortTcpClientAlreadyConnected", + 905: "XPortTcpClientNotConnected", + 906: "XPortTcpConnectionFailure", + 907: "XPortTcpCloseFailure", + 908: "XPortTcpSendError", + 909: "XPortUdpSendError", + 910: "XPortMalformedDiscoveryRequest", + 911: "XPortIpDhcpFailed", + 912: "XPortIpStaticAddressConfigFailed", + 928: "XPortArcNetBufferOverrun", + 929: "XPortArcNetVersionConflict", + 930: "XPortArcNetInvalidFrameType", + 931: "XPortArcNetInvalidFrame", + 932: "XPortArcNetUnknownError", + 933: "XPortArcNetAckHasNoMatchingPacket", + 934: "XPortArcNetInvalidMessageSize", + 935: "XPortArcNetLastMessageFailed", + 936: "XPortArcNetWp0RefusedSyn", + 937: "XPortArcNetWp0MessageTooShort", + 938: "XPortArcNetWp0MessageTooLong", + 939: "XPortArcNetWp0InvalidSequenceNumber", + 940: "XPortArcNetWp0NoResponseFromDestination", + 1024: "ComLinkReferToInnerException", + 1025: "ComLinkNotConnected", + 1026: "ComLinkTcpConnectionFailed", + 1027: "ComLinkFailedToCloseConnectionProperly", + 1028: "ComLinkInvalidProtocolVersion", + 1029: "ComLinkUnsupportedOptionsDetectedByServer", + 1030: "ComLinkNodeIdNegotiationFailure", + 1031: "ComLinkConnectionIntentError", + 1032: "ComLinkUnableToConfigureKeepAlive", + 1033: "ComLinkFailedToSendConnectionPacket", + 1034: "ComLinkInvalidRegistrationAction", + 1035: "ComLinkUnexpectedRequestedHarpAddressReturned", + 1036: "ComLinkHarpAddressRegistrationFailed", + 1037: "ComLinkHarpAddressDeregistrationFailed", + 1038: "ComLinkIdentificationNotImplemented", + 1039: "ComLinkIdentificationNotSupported", + 1040: "ComLinkFailedToSendIdentificationRequest", + 1041: "ComLinkNoResponseFromInstrumentRegistrationServer", + 1042: "ComLinkNoRootObjectFound", + 1043: "ComLinkEthernetObjectNotFound", + 1044: "ComLinkMethodNotFound", + 1045: "ComLinkProtocolActionConversionFailed", + 1046: "ComLinkTimeout", + 1047: "ComLinkUnableToSendOrReceive", + 1048: "ComLinkTransportTransportableIntroductionFailure", + 1049: "ComLinkHarpHarpableIntroductionFailure", + 1050: "ComLinkDownloadException", + 1051: "ComLinkSizeOfReturnParametersNotValid", + 1052: "ComLinkRestrictedMethod", + 1053: "ComLinkInvalidNumberOfStructureParametersFromNetworkLayer", + 1054: "ComLinkInvalidTypeInStructureFromNetworkLayer", + 1055: "ComLinkRs232ConnectionFailed", + 1056: "ComLinkRs232InvalidPort", + 1057: "ComLinkLoggingCannotBeConfiguredWhileConnectedOrConnecting", + 1058: "ComLinkThreadAbortExceptionDetected", + 1059: "ComLinkUnableToSend", + 1060: "ComLinkUnableToReceive", + 1061: "ComLinkConnectionRequiredToProceed", + 1062: "ComLinkTooMuchDataToSend", + 1063: "ComLinkCanConfigurationFailure", + 1064: "ComLinkUnableToRetrieveListOfModules", + 1065: "ComLinkTcpConnectionFailedConnectionRefused", + 1066: "ComLinkTcpConnectionFailedHostUnreachable", + 1067: "ComLinkTcpConnectionFailedHostNotFound", + 1068: "ComLinkTcpConnectionFailedTimedOut", + 1069: "ComLinkTcpConnectionFailedIsConnected", + 32792: "GenericMultipleWarningsReported", +} + +NIMBUS_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + (0x0001, 0x0001, 0x0101, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x0101, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0101, 1, 0x0F03): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F04): "Plate is in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F05): "No plate in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F06): "Invalid tip type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F07): "Tip not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F08): "Tip already installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F09): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0A): "Tip type is not a Gripper tool.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0B): "Invalid aspirate type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0C): "Invalid dispense type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0D): "Invalid LLD mode.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0E): "Sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0F): "Jet dispense with LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F10): "Surface dispense with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F11): "Invalid aspirate dispense pattern.", + (0x0001, 0x0001, 0x0101, 1, 0x0F12): "Pressure differential not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F13): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0101, 1, 0x0F14): "All pressure differentials not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F15): "Y position limit exceeded for channel 1.", + (0x0001, 0x0001, 0x0101, 1, 0x0F16): "Y position limit exceeded for channel 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F17): "Y position limit exceeded for channels 1 and 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F18): "Y position limit exceeded for channel 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F19): "Y position limit exceeded for channels 1 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1A): "Y position limit exceeded for channels 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1B): "Y position limit exceeded for channels 1, 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1C): "Y position limit exceeded for channel 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1D): "Y position limit exceeded for channels 1 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1E): "Y position limit exceeded for channels 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1F): "Y position limit exceeded for channels 1, 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F20): "Y position limit exceeded for channels 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F21): "Y position limit exceeded for channels 1, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F22): "Y position limit exceeded for channels 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F23): "Y position limit exceeded for channels 1, 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F24): "Z position limit exceeded for one or more channels.", + (0x0001, 0x0001, 0x0101, 1, 0x0F25): "Unexpected Vacuum Detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F01): "LLD not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F02): "Invalid channel specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F03): "Gripper not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F04): "LLD unexpectedly detected during Z movement.", + (0x0001, 0x0001, 0x0102, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0102, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0102, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0102, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0102, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0102, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0102, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0102, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0102, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0102, 1, 0x0F14): "Reserved Error 20.", + (0x0001, 0x0001, 0x0102, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0102, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0102, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0102, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0102, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1E): "Reserved Error 30.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1F): "Reserved Error 31.", + (0x0001, 0x0001, 0x0102, 1, 0x0F20): "Reserved Error 32.", + (0x0001, 0x0001, 0x0102, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0102, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0102, 1, 0x0F23): "Reserved Error 35.", + (0x0001, 0x0001, 0x0102, 1, 0x0F24): "Invalid tips specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F25): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0102, 1, 0x0F26): "LLD seeks not within 0.05mm of each other.", + ( + 0x0001, + 0x0001, + 0x0104, + 1, + 0x0F01, + ): "Motion was stopped prematurely via a call to IAxisWrapper.Stop(BOOL hard).", + (0x0001, 0x0001, 0x0105, 1, 0x0F01): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0105, 1, 0x0F02): "One or more Shift N Scan tube racks not installed.", + (0x0001, 0x0001, 0x0105, 1, 0x0F03): "Invalid tips specified.", + (0x0001, 0x0001, 0x0105, 1, 0x0F04): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0106, 1, 0x0F01): "Unable to sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F02): "Invalid parameter combination.", + (0x0001, 0x0001, 0x0106, 1, 0x0F03): "Cannot dispense with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F04): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0108, 1, 0x0F01): "Gripper detects force applied.", + (0x0001, 0x0001, 0x0108, 1, 0x0F02): "Wrist may be in an unsafe location for initialization.", + (0x0001, 0x0001, 0x0108, 1, 0x0F03): "Y axis cannot be moved to safe zone.", + (0x0001, 0x0001, 0x0108, 1, 0x0F04): "Park sensor not active when gripper is parked.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F01, + ): "Deck monitoring is not available in the current hardware configuration.", + (0x0001, 0x0001, 0x010A, 1, 0x0F02): "ConfigureTracks only allowed while monitoring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F03): "LoadTrack only allowed while configuring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F04): "Invalid track position specified.", + (0x0001, 0x0001, 0x010A, 1, 0x0F05): "Track plus width created an invalid track position.", + (0x0001, 0x0001, 0x010A, 1, 0x0F06): "CancelTrack only allowed while configuring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F07, + ): "MonitorDeck(FALSE) only allowed while monitoring and a method is not running.", + (0x0001, 0x0001, 0x010A, 1, 0x0F08): "MonitorDeck(TRUE) only allowed while not monitoring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F09, + ): "LoadTracks2 tracks and widths array sizes are not identical.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F0A, + ): "LoadTracks2 tracks and widths arrays have overlapping tracks.", + (0x0001, 0x0001, 0x010C, 1, 0x0F01): "Right door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F02): "Left door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F03): "Both door locks do not indicate locked.", + ( + 0x0001, + 0x0001, + 0x010C, + 1, + 0x0F04, + ): "Door cannot be unlocked until the instrument completes the command currently in progress.", + (0x0001, 0x0001, 0x010E, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x010E, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x010E, 1, 0x0F03): "Not enough array items for the specified tips.", + ( + 0x0001, + 0x0001, + 0x010F, + 1, + 0x8F01, + ): "Speed exceeds maximum speed of the z and g axis and will not be applied to these axes.", + (0x0001, 0x0001, 0x0110, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0110, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0110, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0110, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0110, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0110, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0110, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0110, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0110, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0110, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0110, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0110, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0110, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0110, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0110, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0110, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0110, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0110, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0110, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0110, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0110, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0110, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0110, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0110, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0110, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0110, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0110, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0110, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0110, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0110, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0110, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0110, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0110, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0110, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0110, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0110, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0110, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0110, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0110, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0110, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0110, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0110, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0110, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0110, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0110, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0111, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0111, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0111, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0111, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0111, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0111, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0111, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0111, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0111, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0111, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0111, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0111, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0111, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0111, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0111, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0111, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0111, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0111, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0111, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0111, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0111, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0111, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0111, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0111, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0111, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0111, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0111, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0111, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0111, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0111, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0111, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0111, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0111, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0111, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0111, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0111, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0111, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0111, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0111, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0111, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0111, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0111, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0111, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0111, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0111, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0112, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0112, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0112, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0112, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0112, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0112, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0112, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0112, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0112, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0112, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0112, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0112, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0112, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0112, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0112, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0112, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0112, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0112, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0112, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0112, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0112, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0112, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0112, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0112, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0112, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0112, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0112, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0112, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0112, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0112, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0112, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0112, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0112, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0112, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0112, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0112, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0112, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0112, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0112, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0112, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0112, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0112, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0112, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0112, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0112, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0113, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0113, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0113, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0113, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0113, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0113, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0113, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0113, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0113, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0113, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0113, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0113, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0113, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0113, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0113, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0113, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0113, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0113, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0113, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0113, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0113, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0113, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0113, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0113, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0113, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0113, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0113, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0113, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0113, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0113, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0113, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0113, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0113, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0113, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0113, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0113, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0113, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0113, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0113, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0113, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0113, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0113, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0113, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0113, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0113, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0114, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0114, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0114, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0114, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0114, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0114, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0114, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0114, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0114, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0114, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0114, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0114, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0114, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0114, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0114, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0114, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0114, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0114, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0114, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0114, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0114, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0114, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0114, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0114, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0114, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0114, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0114, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0114, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0114, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0114, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0114, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0114, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0114, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0114, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0114, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0114, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0114, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0114, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0114, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0114, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0114, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0114, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0114, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0114, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0114, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0115, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0115, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0115, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0115, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0115, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0115, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0115, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0115, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0115, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0115, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0115, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0115, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0115, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0115, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0115, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0115, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0115, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0115, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0115, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0115, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0115, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0115, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0115, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0115, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0115, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0115, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0115, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0115, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0115, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0115, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0115, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0115, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0115, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0115, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0115, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0115, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0115, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0115, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0115, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0115, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0115, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0115, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0115, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0115, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0115, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0116, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0116, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0116, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0116, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0116, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0116, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0116, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0116, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0116, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0116, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0116, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0116, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0116, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0116, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0116, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0116, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0116, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0116, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0116, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0116, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0116, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0116, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0116, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0116, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0116, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0116, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0116, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0116, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0116, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0116, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0116, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0116, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0116, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0116, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0116, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0116, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0116, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0116, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0116, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0116, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0116, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0116, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0116, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0116, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0116, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0117, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0117, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0117, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0117, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0117, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0117, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0117, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0117, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0117, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0117, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0117, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0117, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0117, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0117, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0117, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0117, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0117, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0117, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0117, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0117, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0117, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0117, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0117, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0117, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0117, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0117, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0117, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0117, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0117, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0117, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0117, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0117, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0117, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0117, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0117, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0117, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0117, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0117, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0117, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0117, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0117, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0117, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0117, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0117, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0117, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F01): "The park button is currently disabled.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F02): "The gripper is holding a plate.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F03): "Unknown device identifier.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F04): "No shift and scan racks installed.", + (0x0001, 0x0001, 0xC100, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC100, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC100, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC100, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC100, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC101, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC101, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC101, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC102, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC102, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC102, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC103, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC103, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC103, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC104, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC104, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC104, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC105, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC105, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC105, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC106, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC106, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC106, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC107, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC107, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC107, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0020, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0060, 0x0101, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0101, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0101, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0101, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0101, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + (0x0001, 0x0060, 0x0102, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0102, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0102, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0102, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0102, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0080, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0080, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0080, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0080, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0080, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0080, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0081, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0081, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0081, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0081, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0081, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0081, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0082, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0082, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0082, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0082, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0082, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0082, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0083, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0083, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0083, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0083, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0083, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0083, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + (0x0001, 0xE000, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE001, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE020, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0020, 0x0001, 0x1000, 1, 0x0F01): "The gripper calibration has not yet started.", + (0x0020, 0x0001, 0x1000, 1, 0x0F02): "The flash memory sector contains an invalid magic cookie.", + (0x0020, 0x0001, 0x1000, 1, 0x0F03): "The flash memory sector contains an invalid checksum.", + (0x0020, 0x0001, 0x1000, 1, 0x0F04): "Flash memory sector failed preparation for writing.", + (0x0020, 0x0001, 0x1000, 1, 0x0F05): "Flash memory sectors failed to erase.", + (0x0020, 0x0001, 0x1000, 1, 0x0F06): "Flash memory write failed.", + (0x0020, 0x0001, 0x1000, 1, 0x0F07): "The open width is less than the tool width.", + (0x0020, 0x0001, 0x1000, 1, 0x0F08): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0x1000, 1, 0x0F09): "The calibration tool was not detected.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0A): "The gripper width calibration failed.", + ( + 0x0020, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "The gripper travel extent calibration cannot start because the gripper width calibration is in progress.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0C): "The gripper travel extent calibration verification failed.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F01): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F02): "Not enough force applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F03): "The park sensor has not been enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0044, 0x0004, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0x0004, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0x0004, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", + ( + 0x0020, + 0x0044, + 0x0005, + 1, + 0x0F01, + ): "This error is generated when the current operation resulted in a overflow.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", +} + +# Generated from Hamilton.Module.MLPrep.Service.dll (MLPrepSystem.RegisterErrors). +# Node IDs: 0x00e8=FrontChannel, 0x00ec=RearChannel, 0x00ee=Pipettor. +# Object IDs: 0x0100=Pipettor, 0x0101=Dispenser, 0x0200-0x0205=drives/calibration, +# 0x0107=TADM, 0x1000=MLPrep, 0x1100=MPH, 0x1300-0x1304=Calibration, +# 0x1500=ChannelPresenter, 0x2000=Arm, 0x2200=ArmMotion, 0x3000/0x3100/0x4000-0x4310=Servo/Motor, +# 0x6000=ParticulateSensor, 0xbef0=MLPrepController. +PREP_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + # Global / unaddressed (HarpAddress default) + (0x0000, 0x0000, 0x0000, 0, 0x0E01): "The pipettor channels are busy with an operation.", + (0x0000, 0x0000, 0x0000, 0, 0x0E02): "The supplied channel index is invalid, or not present.", + (0x0000, 0x0000, 0x0000, 0, 0x0E03): "The indicated site is not defined.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E04, + ): "The requested operation cannot be performed while the channel power is removed.", + (0x0000, 0x0000, 0x0000, 0, 0x0E05): "The requested channel does not have a head installed.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E06, + ): "Coordinator Proxy Communication Timeout. Address refers to the target, not the source.", + (0x0000, 0x0000, 0x0000, 0, 0x0E07): "A Calibration procedure is in progress.", + # Pipettor node (0x00ee) — pipetting operations + (0x0001, 0x00EE, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EE, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EE, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EE, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # RearChannel node (0x00ec) — mirrors Pipettor errors + (0x0001, 0x00EC, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EC, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EC, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EC, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # FrontChannel node (0x00e8) — mirrors Pipettor errors + (0x0001, 0x00E8, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00E8, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00E8, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00E8, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # MLPrep node (0x0001) — instrument-level errors + (0x0001, 0x0001, 0x6000, 1, 0x0F01): "The particulate sensor fan is blocked.", + (0x0001, 0x0001, 0x6000, 1, 0x0F02): "The particulate sensor reported an internal issue.", + (0x0001, 0x0001, 0x6000, 1, 0x0F03): "The particulate sensor reported a laser failure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F01): "Cannot change tip definitions when tips are held.", + (0x0001, 0x0001, 0x1500, 1, 0x0F02): "Already in the Power Down Requested state.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F03, + ): "Must request to Power Down before confirming or canceling the procedure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F04): "The channels already have power applied.", + (0x0001, 0x0001, 0x1500, 1, 0x0F05): "No tips can be held during channel head swap.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F06, + ): "The method may only be invoked while the system is in the Suspended state.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F01): "No pipettors registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F02): "No MPH registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F03): "No HHS registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F04): "Cannot manipulate channel axes with tips held.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F05): "No Hod registered with the controller.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F01, + ): "The deck configuration entry for the self calibration fixture is not defined.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F02, + ): "BeginCalibration has not been called before invoking calibration commands.", + (0x0001, 0x0001, 0x1300, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1300, 1, 0x0F04): "The calculated skew in X is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F05): "The calculated skew in Z is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F06): "The MPH Width is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F07): "The operation requires a needle definition.", + (0x0001, 0x0001, 0x1300, 1, 0x0F08): "The Z axis for a channel must be calibrated before X or Y.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F09, + ): "The operation cannot be performed with a calibration in progress.", + (0x0001, 0x0001, 0x1301, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F02): "Calibration is in progress, please finish it first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1301, 1, 0x0F04): "The LLD seeks were not within 0.05mm of eachother.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F05, + ): "The measured width of the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1301, 1, 0x0F06): "The calibration tool was not detected.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F07, + ): "The measured skew in Z for the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1302, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1302, 1, 0x0F02): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1302, 1, 0x0F03): "The LLD seeks were not within 0.15mm of eachother.", + (0x0001, 0x0001, 0x1302, 1, 0x0F04): "The calibration tool was not detected.", + (0x0001, 0x0001, 0x1304, 1, 0x0F01): "A Site ID was represented more than once.", + (0x0001, 0x0001, 0x2000, 1, 0x0F01): "The current command cannot be paused.", + (0x0001, 0x0001, 0x2000, 1, 0x0F02): "There is no paused command to resume.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F03, + ): "The system cannot resume the paused operation with the door open.", + (0x0001, 0x0001, 0x2000, 1, 0x0F04): "The provided X position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F05): "The provided Y position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F06): "The provided Z position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F07): "Duplicate Channel Indices were present when not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F08): "All X positions of a multi-axis move must match.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F09, + ): "Y positions for channels are in conflict, i.e. channels would need to move through each other.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0A): "Cannot initialize with a plate gripped.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0B): "The door is present and open, movement not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0C): "The selected tip ID is not valid.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0D): "The X Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0E): "A channel's Y Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0F): "A channel's Z Axis is not initialized.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F01, + ): "The passed parameters have conflicting Y positions, refer to options.", + (0x0001, 0x0001, 0x2200, 1, 0x0F02): "The given Z position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F03, + ): "An axis move was stopped early. See following errors for additional details.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F04, + ): "A coordinated movement was stopped early. See following errors for additional details.", + (0x0001, 0x0001, 0x2200, 1, 0x0F05): "The provided, or calculated, Y position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F06, + ): "The calculated path is not possible with the current deck and tip definitions.", + (0x0001, 0x0001, 0x3000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x3100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0001, 0x3100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0001, 0x3100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0001, 0x3100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0001, 0x3100, 1, 0x0F17): "Servo Loop Overrun.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x4000, 1, 0x0F02): "The requested motion would exceed a Travel Limit.", + (0x0001, 0x0001, 0x4000, 1, 0x0F03): "The Static Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F04): "The Dynamic Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F05): "The settling time limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F06): "Servo has not been enabled.", + (0x0001, 0x0001, 0x4000, 1, 0x0F07): "No motion profile has been configured.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F08, + ): "The requested seek event cannot be reached from the current state.", + (0x0001, 0x0001, 0x4000, 1, 0x0F09): "The requested seek event was not reached.", + (0x0001, 0x0001, 0x4000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x4200, 1, 0x8F01): "Unread entries were overwritten.", + ( + 0x0001, + 0x0001, + 0x4300, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + ( + 0x0001, + 0x0001, + 0x4310, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x1000, 1, 0x0F01): "No Pipettor is present at the provided index.", + (0x0001, 0x0001, 0x1000, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F04): "Command not valid when a plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F05): "Command not valid when no plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F06): "Command not valid when a tool is held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F07): "Command not valid when no tool is held.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F08, + ): "Unable to command the MPH from this interface, please use the MPH interface.", + (0x0001, 0x0001, 0x1000, 1, 0x0F09): "The held tool is not of a type supported by the operation.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0A): "The indicated channel's head is not installed.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "A channel was specified more than once in an operation where each channel can only be used once.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0C): "The same tip type must be held in each channel.", + (0x0001, 0x0001, 0x1100, 1, 0x0F01): "No MPH is installed.", + (0x0001, 0x0001, 0x1100, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F04): "The MPH cannot pick up a tip with tool definitions.", + ( + 0x0001, + 0x0001, + 0x1100, + 1, + 0x0F05, + ): "Unable to command an Individual Channel from this interface, please use the Pipettor interface.", + (0x0001, 0x0001, 0x1100, 1, 0x0F06): "The MPH head is not installed.", +} diff --git a/pylabrobot/hamilton/transport/tcp/hoi_error.py b/pylabrobot/hamilton/transport/tcp/hoi_error.py new file mode 100644 index 00000000000..9a36241aa06 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/hoi_error.py @@ -0,0 +1,208 @@ +"""HOI exception handling for Hamilton TCP. + +Provides :class:`HoiError` for non-channel ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` +frames, and parsers that turn HOI exception/warning params into +:class:`~pylabrobot.hamilton.transport.tcp.wire_types.HcResultEntry` rows and human-readable +strings. STATUS/COMMAND exception param walking and semicolon-separated HC-result +strings (warning-prefix fragment 1) live here; framing and success response decode +remain in :mod:`pylabrobot.hamilton.transport.tcp.messages`. +""" + +from __future__ import annotations + +import re +from typing import Dict, List, Optional + +from pylabrobot.hamilton.transport.tcp.wire_types import ( + HamiltonDataType, + HcResultEntry, + decode_fragment, +) + +_ERROR_ENTRY_RE: Optional[re.Pattern[str]] = None + + +def _error_entry_pattern() -> re.Pattern[str]: + global _ERROR_ENTRY_RE + if _ERROR_ENTRY_RE is None: + _ERROR_ENTRY_RE = re.compile( + 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]+))?" + ) + return _ERROR_ENTRY_RE + + +def parse_hamilton_error_entries(params: bytes) -> List[HcResultEntry]: + """Extract every ``HcResultEntry`` from HOI exception params. + + Hamilton ``COMMAND_EXCEPTION`` / ``STATUS_EXCEPTION`` responses can carry + one ``HcResultEntry`` per affected channel, serialized as STRING fragments + of the form ``0xMMMM.0xNNNN.0xOOOO:0xII,0xCCCC,0xRRRR`` (address, + interface_id, method_id, hc_result). On a two-channel tip-pickup where both + channels fail, the firmware emits two such strings — returning only the + first one (as the old ``parse_hamilton_error_entry`` did) silently dropped + the second channel's error. + + This walks every fragment and uses ``re.finditer`` within each STRING so + multi-entry fragments are also covered. Returns entries in wire order — the + backend uses ``_channel_index_for_entry(i, entry)`` on each to map to a PLR + channel, matching the warning-frame prefix's ordinal semantics. + """ + pat = _error_entry_pattern() + out: List[HcResultEntry] = [] + if not params: + return out + 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): + return out + data = params[offset + 4 : payload_end] + if type_id == HamiltonDataType.STRING: + text = data.decode("utf-8", errors="replace").rstrip("\x00").strip() + for m in pat.finditer(text): + out.append( + HcResultEntry( + 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), + action_id=int(m.group(5), 16), + result=int(m.group(6), 16) if m.group(6) else 0, + ) + ) + offset = payload_end + return out + + +def parse_hamilton_error_entry(params: bytes) -> Optional[HcResultEntry]: + """Back-compat shim: returns the first entry from :func:`parse_hamilton_error_entries`.""" + entries = parse_hamilton_error_entries(params) + return entries[0] if entries else None + + +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() + 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>") + offset = payload_end + return out + + +def parse_hc_results_from_semicolon_string(text: str) -> list[HcResultEntry]: + """Parse the semicolon-separated HOI result string (e.g. warning-prefix fragment 1). + + Same segment format as ``HoiDecoder2.GetHcResults`` in the vendor stack. + Each segment is ``0xMMMM.0xMMMM.0xMMMM:0xII,0xAAAA,0xRRRR`` (address + iface, action, result). + Malformed segments are skipped, matching the C# try/except behavior. + """ + entries: list[HcResultEntry] = [] + for segment in text.split(";"): + segment = segment.strip() + if not segment: + continue + try: + addr_part, rest = segment.split(":", 1) + addr_part = addr_part.replace("0x", "").replace("0X", "") + rest = rest.replace("0x", "").replace("0X", "") + mod_s, node_s, obj_s = addr_part.split(".", 2) + module_id = int(mod_s, 16) + node_id = int(node_s, 16) + object_id = int(obj_s, 16) + fields = [x.strip() for x in rest.split(",")] + if len(fields) < 3: + continue + interface_id = int(fields[0], 16) + action_id = int(fields[1], 16) + result = int(fields[2], 16) + entries.append( + HcResultEntry( + module_id=module_id, + node_id=node_id, + object_id=object_id, + interface_id=interface_id, + action_id=action_id, + result=result, + ) + ) + except (ValueError, IndexError): + continue + return entries + + +class HoiError(Exception): + """Raised for ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` when the command wire shape + does not carry per-channel parameters (e.g. void MLPrep queries). + + Wraps the same enriched per-entry exceptions as the channelized path + (``describe_entry`` / error tables); :attr:`exceptions` is keyed by **wire entry + index**, not physical channel index. Use :attr:`entries` for raw + :class:`HcResultEntry` data. + """ + + def __init__( + self, + *, + exceptions: Dict[int, Exception], + entries: List[HcResultEntry], + raw_response: bytes, + ) -> None: + self.exceptions = exceptions + self.entries = entries + self.raw_response = raw_response + super().__init__(self._format_message()) + + def _format_message(self) -> str: + parts = [f"entry[{i}]: {self.exceptions[i]}" for i in sorted(self.exceptions)] + return "HoiError(" + "; ".join(parts) + ")" diff --git a/pylabrobot/hamilton/transport/tcp/interface_bundle.py b/pylabrobot/hamilton/transport/tcp/interface_bundle.py new file mode 100644 index 00000000000..260b37638bc --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/interface_bundle.py @@ -0,0 +1,70 @@ +"""Resolve logical interface roles to firmware :class:`Address` values via dot-paths. + +Drivers supply a mapping of role name → :class:`InterfacePathSpec` (path, required flags). +This module performs the shared ``resolve_path`` loop and logging; product-specific +typed bundles (e.g. ``PrepResolvedInterfaces``) live next to each driver. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Mapping, Optional + +from pylabrobot.hamilton.transport.tcp.packets import Address + +if TYPE_CHECKING: + from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class InterfacePathSpec: + """Single logical interface: strict dot-path and resolution policy.""" + + path: str + required: bool + raise_when_missing: bool = True + + +async def resolve_interface_path_specs( + client: HamiltonTCPClient, + specs: Mapping[str, InterfacePathSpec], + *, + instrument_label: str = "instrument", +) -> dict[str, Optional[Address]]: + """Resolve each path; required interfaces fail fast on :exc:`KeyError` from ``resolve_path``.""" + resolved: dict[str, Optional[Address]] = {} + for name, spec in specs.items(): + try: + addr = await client.resolve_path(spec.path) + resolved[name] = addr + logger.debug( + "Resolved %s interface %s → %s (%s)", + instrument_label, + name, + addr, + spec.path, + ) + except KeyError: + if spec.required: + raise RuntimeError( + f"Could not find required interface '{name}' ({spec.path}) on {instrument_label}." + ) from None + resolved[name] = None + if spec.raise_when_missing: + logger.warning( + "Optional %s interface missing: %s (%s)", + instrument_label, + name, + spec.path, + ) + + found = sorted(n for n, a in resolved.items() if a is not None) + missing_opt = sorted(n for n, s in specs.items() if not s.required and resolved.get(n) is None) + logger.info("%s interfaces: %s", instrument_label, ", ".join(found)) + if missing_opt: + logger.info("%s optional not present: %s", instrument_label, ", ".join(missing_opt)) + + return resolved diff --git a/pylabrobot/hamilton/transport/tcp/introspection.py b/pylabrobot/hamilton/transport/tcp/introspection.py index 4a5fff0bbfd..a5149578e89 100644 --- a/pylabrobot/hamilton/transport/tcp/introspection.py +++ b/pylabrobot/hamilton/transport/tcp/introspection.py @@ -1,29 +1,114 @@ """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. +Provides dynamic discovery via Interface 0 methods (GetObject, GetMethod, +GetStructs, GetEnums, GetInterfaces, GetSubobjectAddress). + +:class:`HamiltonIntrospection` receives its transport dependencies (registry, +send_discovery_command, send_query) as explicit callables — no back-reference +to the client. The client constructs it via the lazy +:attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.introspection` property, +which is the **only** supported entry point from application code. + +**Runtime defaults (lazy, cache-friendly):** + +- :meth:`~HamiltonIntrospection.ensure_method_table` / + :meth:`~HamiltonIntrospection.methods_for_interface` — scan GetMethod once per object. +- :meth:`~HamiltonIntrospection.ensure_structs_enums` — fetch GetStructs/GetEnums per + HO interface when needed (e.g. for signature resolution). +- :meth:`~HamiltonIntrospection.ensure_global_type_pool` — build + :class:`GlobalTypePool` once per session for ``source_id=1`` refs. +- :meth:`~HamiltonIntrospection.resolve_signature` — resolves a method string without + a pre-built :class:`TypeRegistry` (unless you pass one). + +**Export / parity / codegen (eager composed dumps):** + +- :meth:`~HamiltonIntrospection.build_type_registry` — full structs/enums per + interface (same wire as composing :meth:`~HamiltonIntrospection.ensure_structs_enums` + for each iface). +- :meth:`~HamiltonIntrospection.build_global_type_pool` — full global walk (does not + use the session singleton; use :meth:`~HamiltonIntrospection.ensure_global_type_pool` + for lazy ``source_id=1`` resolution). + +Example (typical notebook):: + + client = HamiltonTCPClient(host=..., port=...) + await client.setup() + intro = client.introspection + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Any, Dict, List +from enum import IntEnum +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Set, Tuple, Union, cast -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand from pylabrobot.hamilton.transport.tcp.messages import ( + PADDED_FLAG, HoiParams, HoiParamsParser, + inspect_hoi_params, ) from pylabrobot.hamilton.transport.tcp.packets import Address -from pylabrobot.hamilton.transport.tcp.protocol import ( +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.transport.tcp.wire_types import ( + U16, + U32, HamiltonDataType, - HamiltonProtocol, + I8Array, + I32Array, + Str, + StrArray, + U8Array, + U32Array, ) logger = logging.getLogger(__name__) + +class Direction(IntEnum): + """Direction of a method parameter in the HOI introspection type system. + + Column order matches ``_HOI_TYPE_ROWS`` ids tuple: ``ids[Direction]`` gives the + direction-encoded HOI type ID for that row and direction. + """ + + In = 0 + Out = 1 + InOut = 2 + RetVal = 3 + + +async def _subobject_address_and_info( + intro: "HamiltonIntrospection", parent_addr: Address, index: int +) -> Tuple[Address, ObjectInfo]: + """Resolve one subobject index to ``(address, ObjectInfo)`` (shared resolve/tree path).""" + sub_addr = await intro.get_subobject_address(parent_addr, index) + sub_info = await intro.get_object(sub_addr) + return sub_addr, sub_info + + +# Connection/transport errors that should propagate immediately rather than +# being swallowed by introspection catch blocks. A dead connection would +# otherwise cause N individual timeouts (one per method) before the caller +# sees any error. +_TRANSIENT_ERRORS = ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, +) + +# Known network/built-in structs (source_id=3). These types are not queryable +# via introspection — their wire format was determined empirically by calling +# methods that return them (e.g. GetDeckCalibration on PipettorCalibration). +# Populated lazily below after StructInfo is defined. +_NETWORK_STRUCTS: Dict[int, "StructInfo"] = {} + # ============================================================================ # TYPE RESOLUTION HELPERS # ============================================================================ @@ -39,143 +124,117 @@ def resolve_type_id(type_id: int) -> str: Human-readable type name """ try: - return HamiltonDataType(type_id).name + return cast(str, HamiltonDataType(type_id).name) except ValueError: 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 +# INTROSPECTION TYPE MAPPING (2D table from HoiObject.mHoiParamTypes) # ============================================================================ -# Introspection type IDs are separate from HamiltonDataType wire encoding types. -# These are used for method signature display/metadata, not binary encoding. - -# Type ID ranges for categorization: -# - Argument types: Method parameters (input) -# - ReturnElement types: Multiple return values (struct fields) -# - ReturnValue types: Single return value - -_INTROSPECTION_TYPE_NAMES: dict[int, str] = { - # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) - 1: "i8", - 2: "u8", - 3: "i16", - 4: "u16", - 5: "i32", - 6: "u32", - 7: "str", - 8: "bytes", - 33: "bool", - 41: "List[i16]", - 45: "List[u16]", - 49: "List[i32]", - 53: "List[u32]", - 61: "List[struct]", # Complex type, needs source_id + struct_id - 66: "List[bool]", - 82: "List[enum]", # Complex type, needs source_id + enum_id - 102: "f32", - # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) - 18: "u8", - 19: "i16", - 20: "u16", - 21: "i32", - 22: "u32", - 23: "str", - 24: "bytes", - 35: "bool", - 43: "List[i16]", - 47: "List[u16]", - 51: "List[i32]", - 55: "List[u32]", - 68: "List[bool]", - 76: "List[str]", - # ReturnValue types (25-32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105) - 25: "i8", - 26: "u8", - 27: "i16", - 28: "u16", - 29: "i32", - 30: "u32", - 31: "str", - 32: "bytes", - 36: "bool", - 44: "List[i16]", - 48: "List[u16]", - 52: "List[i32]", - 56: "List[u32]", - 69: "List[bool]", - 81: "enum", # Complex type, needs source_id + enum_id - 85: "enum", # Complex type, needs source_id + enum_id - 104: "f32", - 105: "f32", - # Complex types (60, 64, 78) - these need source_id + id - 60: "struct", # ReturnValue, needs source_id + struct_id - 64: "struct", # ReturnValue, needs source_id + struct_id - 78: "enum", # Argument, needs source_id + enum_id -} - -# 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} -_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 - - -def get_introspection_type_category(type_id: int) -> str: - """Get category for introspection type ID. +# Each row maps one wire kind (HamiltonDataType) × 4 directions (Direction enum) +# to the direction-encoded HOI type IDs the firmware uses in GetMethod responses. +# Source: vendor protocol reference mHoiParamTypes[31,4]. - Args: - type_id: Introspection type ID - - Returns: - Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" - """ - if type_id in _ARGUMENT_TYPE_IDS: - return "Argument" - elif type_id in _RETURN_ELEMENT_TYPE_IDS: - return "ReturnElement" - elif type_id in _RETURN_VALUE_TYPE_IDS: - return "ReturnValue" - else: - return "Unknown" +@dataclass(frozen=True) +class _HoiTypeRow: + """One row in vendor mHoiParamTypes[31,4] with readable display metadata. -def resolve_introspection_type_name(type_id: int) -> str: - """Resolve introspection type ID to readable name. + ``ids`` always follows the interface-0 type table column order: + ``(In, Out, InOut, RetVal)``. These columns are specific to the firmware's + interface-0 HOI type system, a unique typing scheme separate from the + standard ``HamiltonDataType`` wire type IDs. - Args: - type_id: Introspection type ID + ``wire_type``: the ``HamiltonDataType`` that this HOI kind maps to on the wire. + This is the bridge between the two type systems: HOI introspection IDs are + direction-encoded variants of a ``wire_type`` kind. - Returns: - Human-readable type name + ``is_complex``: type requires additional source_id/ref_id bytes in method param encoding. + ``is_struct_kind``: type references a struct definition (subset of complex). + ``is_enum_kind``: type references an enum definition (subset of complex). """ - return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + display_name: str + ids: tuple[int, int, int, int] # Interface-0 column order: [In, Out, InOut, RetVal] + wire_type: HamiltonDataType = HamiltonDataType.VOID + is_complex: bool = False + is_struct_kind: bool = False + is_enum_kind: bool = False + + +_HOI_TYPE_ROWS: tuple[_HoiTypeRow, ...] = ( + _HoiTypeRow("i8", (1, 17, 9, 25), HamiltonDataType.I8), + _HoiTypeRow("i16", (3, 19, 11, 27), HamiltonDataType.I16), + _HoiTypeRow("i32", (5, 21, 13, 29), HamiltonDataType.I32), + _HoiTypeRow("u8", (2, 18, 10, 26), HamiltonDataType.U8), + _HoiTypeRow("u16", (4, 20, 12, 28), HamiltonDataType.U16), + _HoiTypeRow("u32", (6, 22, 14, 30), HamiltonDataType.U32), + _HoiTypeRow("str", (7, 23, 15, 31), HamiltonDataType.STRING), + _HoiTypeRow("bool", (33, 35, 34, 36), HamiltonDataType.BOOL), + _HoiTypeRow("List[i8]", (37, 39, 38, 40), HamiltonDataType.I8_ARRAY), + _HoiTypeRow("List[i16]", (41, 43, 42, 44), HamiltonDataType.I16_ARRAY), + _HoiTypeRow("List[i32]", (49, 51, 50, 52), HamiltonDataType.I32_ARRAY), + _HoiTypeRow("bytes", (8, 24, 16, 32), HamiltonDataType.U8_ARRAY), + _HoiTypeRow("List[u16]", (45, 47, 46, 48), HamiltonDataType.U16_ARRAY), + _HoiTypeRow("List[u32]", (53, 55, 54, 56), HamiltonDataType.U32_ARRAY), + _HoiTypeRow("List[bool]", (66, 68, 67, 69), HamiltonDataType.BOOL_ARRAY), + _HoiTypeRow("HcResult", (70, 72, 71, 73), HamiltonDataType.HC_RESULT, is_complex=True), + _HoiTypeRow( + "struct", (57, 59, 58, 60), HamiltonDataType.STRUCTURE, is_complex=True, is_struct_kind=True + ), + _HoiTypeRow( + "List[struct]", + (61, 63, 62, 64), + HamiltonDataType.STRUCTURE_ARRAY, + is_complex=True, + is_struct_kind=True, + ), + _HoiTypeRow("List[str]", (74, 76, 75, 77), HamiltonDataType.STRING_ARRAY, is_complex=True), + _HoiTypeRow("enum", (78, 80, 79, 81), HamiltonDataType.ENUM, is_complex=True, is_enum_kind=True), + _HoiTypeRow( + "List[enum]", (82, 84, 83, 85), HamiltonDataType.ENUM_ARRAY, is_complex=True, is_enum_kind=True + ), + _HoiTypeRow("i64", (86, 88, 87, 89), HamiltonDataType.I64), + _HoiTypeRow("u64", (90, 92, 91, 93), HamiltonDataType.U64), + _HoiTypeRow("f32", (94, 96, 95, 97), HamiltonDataType.F32), + _HoiTypeRow("f64", (98, 100, 99, 101), HamiltonDataType.F64), + _HoiTypeRow("List[i64]", (102, 104, 103, 105), HamiltonDataType.I64_ARRAY), + _HoiTypeRow("List[u64]", (106, 108, 107, 109), HamiltonDataType.U64_ARRAY), + _HoiTypeRow("List[f32]", (110, 112, 111, 113), HamiltonDataType.F32_ARRAY), + _HoiTypeRow("List[f64]", (114, 116, 115, 117), HamiltonDataType.F64_ARRAY), + _HoiTypeRow("HoiResult", (118, 120, 119, 121), HamiltonDataType.HOI_RESULT, is_complex=True), + _HoiTypeRow("padding", (0, 0, 0, 0), HamiltonDataType.VOID), +) -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 +# HOI method-param type IDs that require extra source_id/ref_id bytes on the wire +# (rows where is_complex=True). Used as a parsing guard in _parse_method_param_types. +_COMPLEX_METHOD_TYPE_IDS: frozenset[int] = frozenset( + tid for row in _HOI_TYPE_ROWS if row.is_complex for tid in row.ids if tid != 0 +) - Args: - type_id: Introspection type ID +# GetStructs wire sentinels for complex field types (HamiltonDataType namespace, not HOI). +# Used as a parsing guard in _parse_struct_field_types. +_COMPLEX_STRUCT_TYPE_IDS: frozenset[int] = frozenset( + { + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + } +) - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS +# Reverse lookup: direction-encoded HOI ID → (wire_type, Direction). +# Built from _HOI_TYPE_ROWS: each row encodes one wire kind × 4 directions. +# This is the bridge between the HOI introspection namespace and HamiltonDataType. +_HOI_ID_TO_WIRE: Dict[int, Tuple[HamiltonDataType, Direction]] = {} +for _row in _HOI_TYPE_ROWS: + for _ci, _tid in enumerate(_row.ids): + if _tid != 0: + _HOI_ID_TO_WIRE[_tid] = (_row.wire_type, Direction(_ci)) +# Empirical: ID 113 (List[f32] RetVal column) observed as In argument on some firmware. +# TODO: Re-validate against hardware captures and remove if no longer observed. +_HOI_ID_TO_WIRE[113] = (HamiltonDataType.F32_ARRAY, Direction.In) # ============================================================================ @@ -192,6 +251,362 @@ class ObjectInfo: method_count: int subobject_count: int address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) + + +class ObjectRegistry: + """Pure key-value cache: path ↔ ObjectInfo and address → path. + + No async logic; all traversal lives in :class:`HamiltonIntrospection`. + """ + + def __init__(self): + self._objects: Dict[str, ObjectInfo] = {} + self._address_to_path: Dict[Address, str] = {} + self._root_address: Optional[Address] = None + + def set_root_address(self, address: Address) -> None: + self._root_address = address + + def get_root_address(self) -> Optional[Address]: + return self._root_address + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + self._address_to_path[obj.address] = path + + def address_for(self, path: str) -> Optional[Address]: + obj = self._objects.get(path) + return obj.address if obj is not None else None + + def path(self, address: Address) -> Optional[str]: + return self._address_to_path.get(address) + + +@dataclass +class FirmwareTreeNode: + """One node in a discovered firmware object tree.""" + + path: str + address: Address + object_info: ObjectInfo + supported_interface0_methods: Set[int] = field(default_factory=set) + children: List["FirmwareTreeNode"] = field(default_factory=list) + + def format_lines( + self, prefix: str = "", is_last: bool = True, is_root: bool = False + ) -> List[str]: + # Most objects expose the full Interface-0 contract (1..6). Hide it in + # default rendering to keep large trees readable; only show deviations. + full_i0_contract = {1, 2, 3, 4, 5, 6} + show_i0 = self.supported_interface0_methods != full_i0_contract + i0_suffix = "" + if show_i0: + method_ids = ",".join(str(v) for v in sorted(self.supported_interface0_methods)) + i0_suffix = f", i0=[{method_ids}]" + branch = "" if is_root else ("└─ " if is_last else "├─ ") + lines = [ + f"{prefix}{branch}{self.path} @ {self.address} " + f"(methods={self.object_info.method_count}, subobjects={self.object_info.subobject_count}" + f"{i0_suffix})" + ] + child_prefix = prefix + (" " if is_last or is_root else "│ ") + for idx, child in enumerate(self.children): + child_is_last = idx == len(self.children) - 1 + lines.extend(child.format_lines(prefix=child_prefix, is_last=child_is_last, is_root=False)) + return lines + + def __str__(self) -> str: + return "\n".join(self.format_lines(is_root=True)) + + +def flatten_firmware_tree(node: FirmwareTreeNode) -> List[Tuple[str, Address, ObjectInfo]]: + """Preorder flattening of a :class:`FirmwareTreeNode` for path-keyed lookups. + + Returns ``(dot_path, address, object_info)`` for each node (root first, DFS). + """ + out: List[Tuple[str, Address, ObjectInfo]] = [] + + def walk(n: FirmwareTreeNode) -> None: + out.append((n.path, n.address, n.object_info)) + for child in n.children: + walk(child) + + walk(node) + return out + + +@dataclass +class MethodParamType: + """A method parameter or return type from GetMethod, in the HOI introspection namespace. + + ``wire_type`` is the ``HamiltonDataType`` this HOI kind maps to on the wire — + the bridge between the direction-encoded HOI IDs and the wire encoding layer. + ``direction`` records whether this entry is In/Out/InOut/RetVal in the method signature. + + Struct/enum references additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + wire_type: HamiltonDataType + direction: Direction + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob + + @property + def is_struct_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + @property + def is_argument(self) -> bool: + """True if this is an input parameter (In or InOut).""" + return self.direction in (Direction.In, Direction.InOut) + + @property + def is_return(self) -> bool: + """True if this is a return value (Out or RetVal).""" + return self.direction in (Direction.Out, Direction.RetVal) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable name, optionally using a TypeRegistry for struct/enum names.""" + base = self.wire_type.name.lower() + if self.source_id is None or self.ref_id is None: + return base + if self.is_struct_ref: + if registry is not None: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return s.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_enum_ref: + if registry is not None: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_method_param_types( + data: bytes | list[int], +) -> List[MethodParamType]: + """Parse GetMethod parameterTypes byte stream. + + Source: HoiObject.HandleStruct in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_METHOD_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global): ``[type_id, 4, index, '"', FormatAddress_bytes..., '"', ' ']``. + FormatAddress encodes Module+Node as hex byte pairs, wrapped in ASCII double-quotes. + The index byte is the struct/enum index within the node-global pool. + """ + _NODE_GLOBAL = 4 + _QUOTE = 0x22 + _SPACE = 0x20 + + ints = list(data) if isinstance(data, bytes) else data + result: List[MethodParamType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type, direction = _HOI_ID_TO_WIRE.get(tid, (HamiltonDataType.VOID, Direction.In)) + if tid in _COMPLEX_METHOD_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, '"', FormatAddress_bytes..., '"', ' '] + end = i + 4 # byte after opening '"' + while end < len(ints) and ints[end] != _QUOTE: + end += 1 + end += 1 # consume closing '"' + if end < len(ints) and ints[end] == _SPACE: + end += 1 # consume trailing ' ' + result.append( + MethodParamType( + wire_type, direction, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i + ) + ) + i = end + else: + result.append( + MethodParamType(wire_type, direction, source_id=source_id, ref_id=ref_id, _byte_width=3) + ) + i += 3 + else: + result.append(MethodParamType(wire_type, direction)) + i += 1 + return result + + +@dataclass +class StructFieldType: + """A struct field type from GetStructs, in the HamiltonDataType wire namespace. + + ``type_id`` is a ``HamiltonDataType`` value — the wire encoding type for this field. + Unlike ``MethodParamType``, struct fields have no direction concept. + + Complex references (STRUCTURE/ENUM) additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + type_id: HamiltonDataType + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob (1=simple, 3=ref, 7=node-global) + + @property + def is_complex(self) -> bool: + return self.type_id in ( + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + ) + + @property + def is_struct_ref(self) -> bool: + return self.type_id in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.type_id in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable type name, optionally using a TypeRegistry for struct/enum names.""" + if self.is_complex and self.source_id is not None and self.ref_id is not None: + if registry is not None: + if self.is_struct_ref: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return f"struct({s.name})" + elif self.is_enum_ref: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"ref(iface={self.source_id}, id={self.ref_id})" + return resolve_type_id(self.type_id) + + +def _parse_struct_field_types( + data: bytes | list[int], +) -> List[StructFieldType]: + """Parse GetStructs structureElementTypes byte stream. + + Source: HoiObject.GetStructs in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_STRUCT_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global, scope.mAddress.ModuleID != 0): + ``[type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo]`` — 7 bytes. + The 4 raw address bytes are written when the node-global object has a non-zero + ModuleID, which is always true for real node-global objects on this instrument. + """ + _NODE_GLOBAL = 4 + _NODE_GLOBAL_WIDTH = 7 + + ints = list(data) if isinstance(data, bytes) else data + result: List[StructFieldType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type = HamiltonDataType(tid) + if tid in _COMPLEX_STRUCT_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo] = 7 bytes + result.append( + StructFieldType( + wire_type, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH + ) + ) + i += _NODE_GLOBAL_WIDTH + else: + result.append(StructFieldType(wire_type, source_id=source_id, ref_id=ref_id, _byte_width=3)) + i += 3 + else: + result.append(StructFieldType(wire_type)) + i += 1 + return result + + +def _parse_type_ids(raw: str | bytes | None) -> List[MethodParamType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_method_param_types. + + 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_method_param_types(data) + + +@dataclass +class MethodFieldDescriptor: + """Canonical representation of one method parameter/return field.""" + + name: str + type_name: str + + +@dataclass +class MethodDescriptor: + """Canonical normalized representation of a method signature.""" + + interface_id: int + method_id: int + name: str + params: list[MethodFieldDescriptor] = field(default_factory=list) + returns: list[MethodFieldDescriptor] = field(default_factory=list) + return_shape: Literal["void", "scalar", "record"] = "void" + + @property + def id_string(self) -> str: + return f"[{self.interface_id}:{self.method_id}]" + + def signature_string(self) -> str: + """Render the canonical method descriptor as a signature string.""" + if self.params: + param_str = ", ".join(f"{p.name}: {p.type_name}" for p in self.params) + else: + param_str = "void" + + if self.return_shape == "void" or not self.returns: + return_str = "void" + elif self.return_shape == "scalar" and len(self.returns) == 1: + ret = self.returns[0] + return_str = f"{ret.name}: {ret.type_name}" if ret.name != "ret0" else ret.type_name + else: + return_str = "{ " + ", ".join(f"{r.name}: {r.type_name}" for r in self.returns) + " }" + + return f"{self.id_string} {self.name}({param_str}) -> {return_str}" + + def to_dict(self) -> dict: + return { + "name": self.name, + "id": self.id_string, + "signature": self.signature_string(), + "params": [{"name": p.name, "type": p.type_name} for p in self.params], + "returns": [{"name": r.name, "type": r.type_name} for r in self.returns], + } @dataclass @@ -202,65 +617,196 @@ 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 + parameter_types: list[MethodParamType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[MethodParamType] = field(default_factory=list) + return_labels: list[str] = field(default_factory=list) + + def describe(self, registry: Optional["TypeRegistry"] = None) -> MethodDescriptor: + """Return the canonical normalized method descriptor used by all serializers.""" + iid = self.interface_id + params: list[MethodFieldDescriptor] = [] if self.parameter_types: - param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] - - # If we have labels, use them; otherwise just show 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 + param_type_names = [ + pt.resolve_name(registry, ho_interface_id=iid) for pt in self.parameter_types + ] + for i, type_name in enumerate(param_type_names): + label = self.parameter_labels[i] if i < len(self.parameter_labels) else None + params.append(MethodFieldDescriptor(name=label or f"arg{i}", type_name=type_name)) + + returns: list[MethodFieldDescriptor] = [] + return_shape: Literal["void", "scalar", "record"] = "void" 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 - 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: - return_str = return_type_names[0] - else: - return_str = "void" - else: - return_str = "void" + return_type_names = [ + rt.resolve_name(registry, ho_interface_id=iid) for rt in self.return_types + ] + for i, type_name in enumerate(return_type_names): + label = self.return_labels[i] if i < len(self.return_labels) else None + returns.append(MethodFieldDescriptor(name=label or f"ret{i}", type_name=type_name)) + if len(returns) == 1 and not any(rt.direction == Direction.Out for rt in self.return_types): + return_shape = "scalar" + elif len(returns) > 0: + # Includes Out/ReturnElement records and explicit multi-return methods. + return_shape = "record" + + return MethodDescriptor( + interface_id=self.interface_id, + method_id=self.method_id, + name=self.name, + params=params, + returns=returns, + return_shape=return_shape, + ) - return f"{self.name}({param_str}) -> {return_str}" + 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 structure(source=2, ref=1)). + """ + return self.describe(registry).signature_string() + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return self.describe(registry).to_dict() + + +@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. + + Source ID semantics (from piglet — the middle byte of a struct/enum type triple): + source_id=1: Global pool (shared type definitions from global objects); ref_id is + 1-based into that flat list (see GlobalTypePool.resolve_struct). + source_id=2: Local types on this object; ref_id is 1-based into the per-interface + struct/enum maps in self.structs / self.enums (struct_id / enum_id from GetStructs + / GetEnums is 0-based). This is NOT ``HOI interface id 2``; ``2`` means *local* + in the type encoding; tables are keyed by real interface ids (typically ``1`` for + ``[1:*]`` methods alongside introspection on ``0``). + source_id=3: Built-in / network types (e.g. NetworkResult-shaped); resolve_struct + does not decode these yet — validate behavior vs Piglet or device captures. + + source_id=0 is not emitted by firmware; treat any such ref as unresolvable. + + For source_id=2, pass ``ho_interface_id`` on ``resolve_struct`` / ``resolve_enum`` so + lookup is strict to the owning interface's local table. + + Example (full export registry):: + + 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, ...) + + For notebooks and runtime tooling, prefer :meth:`~HamiltonIntrospection.resolve_signature` + (lazy types) instead of building a full registry first. + """ + + 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) + methods: List[MethodInfo] = field(default_factory=list) + global_pool: Optional["GlobalTypePool"] = None + + def resolve_struct( + self, + source_id: int, + ref_id: int, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["StructInfo"]: + """Look up a struct by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id; see GlobalTypePool.resolve_struct). + source_id=2: Local structs (1-based ref_id -> 0-based struct_id in + ``self.structs[ho_interface_id]``). ``ho_interface_id`` is required for + deterministic interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_struct(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.structs.get(ho_interface_id, {}).get(idx) + if source_id == 3: + return _NETWORK_STRUCTS.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, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["EnumInfo"]: + """Look up an enum by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id). + source_id=2: Local enums (same rules as resolve_struct). ``ho_interface_id`` is + required for strict interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_enum(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.enums.get(ho_interface_id, {}).get(idx) + 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.""" + 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 to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + addr = ( + f"{self.address.module}:{self.address.node}:{self.address.object}" if self.address else None + ) + structs_out: Dict[int, List[dict[str, Any]]] = {} + for iid, struct_table in sorted(self.structs.items()): + structs_out[iid] = [s.to_dict(self) for _, s in sorted(struct_table.items())] + enums_out: Dict[int, List[dict[str, Any]]] = {} + for iid, enum_table in sorted(self.enums.items()): + enums_out[iid] = [e.to_dict() for _, e in sorted(enum_table.items())] + return { + "address": addr, + "interfaces": [info.to_dict() for _, info in sorted(self.interfaces.items())], + "methods": [m.to_dict(self) for m in self.methods], + "structs": structs_out, + "enums": enums_out, + } + + 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 @@ -271,6 +817,10 @@ class InterfaceInfo: name: str version: str + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"interface_id": self.interface_id, "name": self.name, "version": self.version} + @dataclass class EnumInfo: @@ -280,35 +830,143 @@ class EnumInfo: name: str values: Dict[str, int] + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"name": self.name, "enum_id": self.enum_id, "values": dict(self.values)} + @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 ``StructFieldType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (STRUCTURE/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, "StructFieldType"] # field_name -> StructFieldType + 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: sft.resolve_name() for name, sft in self.fields.items()} + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + ho_iid = self.interface_id + fields = { + name: sft.resolve_name(registry, ho_interface_id=ho_iid) for name, sft in self.fields.items() + } + d: dict = {"name": self.name, "struct_id": self.struct_id, "fields": fields} + if self.interface_id is not None: + d["interface_id"] = self.interface_id + return d + + 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. + """ + ho_iid = self.interface_id field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + f"{name}: {sft.resolve_name(registry, ho_interface_id=ho_iid)}" + for name, sft in self.fields.items() ] fields_str = "\n ".join(field_strs) if field_strs else " (empty)" return f"struct {self.name} {{\n {fields_str}\n}}" +# Populate known network structs now that StructInfo is defined. +# ref_id=3: DateTime — 7 fields: year(U16), month(U8), day(U8), hour(U8), +# minute(U8), second(U8), millisecond(U16). Wire format confirmed via +# GetDeckCalibration on PipettorCalibration. +_NETWORK_STRUCTS[3] = StructInfo( + struct_id=3, + name="DateTime", + fields={ + "year": StructFieldType(HamiltonDataType.U16), + "month": StructFieldType(HamiltonDataType.U8), + "day": StructFieldType(HamiltonDataType.U8), + "hour": StructFieldType(HamiltonDataType.U8), + "minute": StructFieldType(HamiltonDataType.U8), + "second": StructFieldType(HamiltonDataType.U8), + "millisecond": StructFieldType(HamiltonDataType.U16), + }, + interface_id=3, +) + + +@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 to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return { + "structs": [s.to_dict() for s in self.structs], + "enums": [e.to_dict() for e in self.enums], + } + + 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 = 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. + + # ============================================================================ # INTROSPECTION COMMAND CLASSES # ============================================================================ -class GetObjectCommand(HamiltonCommand): +class GetObjectCommand(TCPCommand): """Get object metadata (command_id=1).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -319,26 +977,15 @@ 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: U32 + subobject_count: U16 -class GetMethodCommand(HamiltonCommand): +class GetMethodCommand(TCPCommand): """Get method signature (command_id=2).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -364,67 +1011,67 @@ 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) - parameter_types_str = None + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs): 3 bytes [type_id, source_id, ref_id] for source_id 1–3; + # node-global (source_id=4): variable-length quote-delimited form — see _parse_method_param_types. + # Labels are comma-separated, one per *logical* parameter (matching MethodParamType count). 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() - # 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] - - # 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[MethodParamType] = [] parameter_labels: list[str] = [] - return_types: list[int] = [] + return_types: list[MethodParamType] = [] 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): label = all_labels[i] if i < len(all_labels) else None - if category == "Argument": - parameter_types.append(type_id) + if pt.is_argument: + parameter_types.append(pt) if label: parameter_labels.append(label) - elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) + elif pt.is_return: + 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) - if label: - parameter_labels.append(label) + raise ValueError( + f"Unknown HOI wire_type={pt.wire_type!r} direction={pt.direction!r}; " + "not in _HOI_ID_TO_WIRE — update _HOI_TYPE_ROWS or add an override." + ) return { "interface_id": interface_id, "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, } -class GetSubobjectAddressCommand(HamiltonCommand): +class GetSubobjectAddressCommand(TCPCommand): """Get subobject address (command_id=3).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -438,22 +1085,21 @@ 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 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_subobject_address response.""" - parser = HoiParamsParser(data) + return HoiParams().u16(self.subobject_index) - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 - return {"address": Address(module_id, node_id, object_id)} +class GetInterfacesCommand(TCPCommand): + """Get available interfaces (command_id=4). -class GetInterfacesCommand(HamiltonCommand): - """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 @@ -463,25 +1109,20 @@ 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) + @dataclass(frozen=True) + class Response: + interface_ids: I8Array + interface_names: StrArray - 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}) +class GetEnumsCommand(TCPCommand): + """Get enum definitions (command_id=5). - return {"interfaces": interfaces} - - -class GetEnumsCommand(HamiltonCommand): - """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 @@ -496,32 +1137,15 @@ 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) - - 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 + @dataclass(frozen=True) + class Response: + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray - enums.append({"enum_id": enum_id, "name": name, "values": values}) - return {"enums": enums} - - -class GetStructsCommand(HamiltonCommand): +class GetStructsCommand(TCPCommand): """Get struct definitions (command_id=6).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -537,29 +1161,36 @@ 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) + @dataclass(frozen=True) + class Response: + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. - structs = [] - _, struct_count = parser.parse_next() + 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. + """ - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() + struct_names: StrArray + field_counts: U32Array + field_type_ids: U8Array + field_names: StrArray - # 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 - structs.append({"struct_id": struct_id, "name": name, "fields": fields}) +# ============================================================================ +# 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). - return {"structs": structs} +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 # ============================================================================ @@ -568,15 +1199,435 @@ def parse_response_parameters(cls, data: bytes) -> dict: class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" + """High-level API for Hamilton introspection. - def __init__(self, backend): - """Initialize introspection API. + 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. - Args: - backend: TCPBackend instance + Dependencies are injected as explicit callables rather than a back-reference + to the client, avoiding the circular reference and the need for a Protocol shim. + Prefer :attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.introspection` + over constructing this class directly from application code. + """ + + def __init__( + self, + registry: ObjectRegistry, + global_object_addresses: list[Address], + send_discovery_command: Callable, + send_query: Callable, + ): + self._registry = registry + self._global_object_addresses = global_object_addresses + self._send_discovery_command = send_discovery_command + self._send_query = send_query + # Session caches (invalidated by replacing the HamiltonIntrospection instance, e.g. reconnect). + self._method_table_by_address: Dict[Address, List[MethodInfo]] = {} + self._iface_types: Dict[ + Tuple[Address, int], Tuple[Dict[int, StructInfo], Dict[int, EnumInfo]] + ] = {} + self._interfaces_by_address: Dict[Address, List[InterfaceInfo]] = {} + self._hc_result_text_by_addr_iface: Dict[Tuple[Address, int], Dict[int, str]] = {} + self._supported_i0_by_address: Dict[Address, Set[int]] = {} + self._global_type_pool_singleton: Optional[GlobalTypePool] = None + self._firmware_tree_cache: Optional[FirmwareTreeNode] = None + + def clear_session_caches(self) -> None: + """Drop cached method tables, per-interface structs/enums, and the global type pool.""" + self._method_table_by_address.clear() + self._iface_types.clear() + self._interfaces_by_address.clear() + self._hc_result_text_by_addr_iface.clear() + self._supported_i0_by_address.clear() + self._global_type_pool_singleton = None + self._firmware_tree_cache = None + + def _attach_iface_types_to_registry( + self, registry: TypeRegistry, addr: Address, iface_id: int + ) -> None: + """Copy cached structs/enums for (addr, iface_id) into *registry*.""" + entry = self._iface_types.get((addr, iface_id)) + if entry is not None: + structs_map, enums_map = entry + registry.structs[iface_id] = dict(structs_map) + registry.enums[iface_id] = dict(enums_map) + + async def _ensure_parameter_types_for_signature( + self, + addr: Address, + method: MethodInfo, + registry: TypeRegistry, + ) -> None: + """Load structs/enums needed to resolve *method* signatures (recursive struct walk).""" + seen_structs: Set[Tuple[int, int]] = set() + max_nodes = 256 + + async def walk(types: Sequence[Union[MethodParamType, StructFieldType]], ho_iface: int) -> None: + for pt in types: + if pt.source_id is None or pt.ref_id is None: + continue + if pt.source_id in (1, 3): + continue + if pt.source_id != 2: + continue + if pt.is_enum_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + continue + if pt.is_struct_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + st = registry.resolve_struct(2, pt.ref_id, ho_interface_id=ho_iface) + if st is None: + continue + field_iface = st.interface_id if st.interface_id is not None else ho_iface + sig = (field_iface, st.struct_id) + if sig in seen_structs: + continue + if len(seen_structs) >= max_nodes: + logger.warning( + "signature struct walk exceeded %d nodes for %s.%s", + max_nodes, + method.name, + st.name, + ) + return + seen_structs.add(sig) + await walk(list(st.fields.values()), field_iface) + + await walk(method.parameter_types, method.interface_id) + await walk(method.return_types, method.interface_id) + + async def _build_minimal_registry_for_signature( + self, addr: Address, method: MethodInfo + ) -> TypeRegistry: + """TypeRegistry with global pool + lazily filled local tables for *method*.""" + pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=addr, global_pool=pool) + await self._ensure_parameter_types_for_signature(addr, method, registry) + return registry + + async def _build_global_type_pool_impl(self, global_addresses: List[Address]) -> GlobalTypePool: + """Walk global objects and build a :class:`GlobalTypePool` (full firmware-scale pass).""" + 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, _supported=supported) + # source_id=1 refs index into the first non-zero interface's struct/enum list + # (firmware always resolves via interface_id=1; see HoiObject.HandleStruct). + # Populate interface_structs for all interfaces, but only extend the flat pool + # from the first non-zero interface so ref_ids remain valid. + first_nonzero_seen = False + for iface in interfaces: + if iface.interface_id == 0: + continue + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if not first_nonzero_seen: + pool.structs.extend(structs) + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + if not first_nonzero_seen: + pool.enums.extend(enums) + first_nonzero_seen = True + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("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 ensure_method_table( + self, + address: Union[Address, str], + *, + _supported: Optional[Set[int]] = None, + _object_info: Optional[ObjectInfo] = None, + ) -> List[MethodInfo]: + """Scan Interface 0 GetMethod for *address* once and cache the full ``MethodInfo`` table. + + Pass ``_object_info`` / ``_supported`` when the caller already has them to avoid redundant + Interface-0 queries on the cold path. + """ + addr = await self._resolve_target_address(address) + cached = self._method_table_by_address.get(addr) + if cached is not None: + return cached + cached_supported = self._supported_i0_by_address.get(addr) + if cached_supported is not None and GET_METHOD not in cached_supported: + self._method_table_by_address[addr] = [] + return [] + if _supported is not None and GET_METHOD not in _supported: + self._supported_i0_by_address[addr] = set(_supported) + self._method_table_by_address[addr] = [] + return [] + if _object_info is None: + _object_info = await self.get_object(addr) + methods: List[MethodInfo] = [] + for i in range(_object_info.method_count): + try: + method = await self.get_method(addr, i) + methods.append(method) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("Failed to get method %d for %s: %s", i, addr, e) + self._method_table_by_address[addr] = methods + self._supported_i0_by_address[addr] = {m.method_id for m in methods if m.interface_id == 0} + return methods + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + """Return methods for *interface_id* using the cached method table when warm.""" + addr = await self._resolve_target_address(address) + table = await self.ensure_method_table(addr) + return [m for m in table if m.interface_id == interface_id] + + async def ensure_structs_enums(self, address: Union[Address, str], interface_id: int) -> None: + """Run GetStructs/GetEnums for one HO interface and cache under ``(address, interface_id)``.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key in self._iface_types: + return + supported = await self.get_supported_interface0_method_ids(addr) + structs_map: Dict[int, StructInfo] = {} + enums_map: Dict[int, EnumInfo] = {} + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, interface_id) + structs_map = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, interface_id) + enums_map = {e.enum_id: e for e in enums} + self._iface_types[key] = (structs_map, enums_map) + hc_result = next((e for e in enums_map.values() if e.name == "HcResult"), None) + if hc_result is not None: + self._hc_result_text_by_addr_iface[key] = {int(v): n for n, v in hc_result.values.items()} + else: + self._hc_result_text_by_addr_iface[key] = {} + + async def get_interface_name( + self, address: Union[Address, str], interface_id: int + ) -> Optional[str]: + """Return interface name for ``(address, interface_id)`` using session cache.""" + addr = await self._resolve_target_address(address) + infos = self._interfaces_by_address.get(addr) + if infos is None: + infos = await self.get_interfaces(addr) + self._interfaces_by_address[addr] = infos + for info in infos: + if info.interface_id == interface_id: + return info.name + return None + + async def get_hc_result_text( + self, address: Union[Address, str], interface_id: int, code: int + ) -> Optional[str]: + """Resolve HcResult enum text for one interface using cached enums.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key not in self._iface_types: + await self.ensure_structs_enums(addr, interface_id) + return self._hc_result_text_by_addr_iface.get(key, {}).get(code) + + async def ensure_global_type_pool( + self, global_addresses: Optional[Sequence[Address]] = None + ) -> GlobalTypePool: + """Return the session-global :class:`GlobalTypePool` (``source_id=1``), building once.""" + if self._global_type_pool_singleton is not None: + return self._global_type_pool_singleton + addrs = ( + list(global_addresses) + if global_addresses is not None + else list(self._global_object_addresses) + ) + self._global_type_pool_singleton = await self._build_global_type_pool_impl(addrs) + return self._global_type_pool_singleton + + async def signature_lines_for_interface( + self, + address: Union[Address, str], + interface_id: int, + *, + max_methods: int = 50, + ) -> List[str]: + """Resolved signature strings for up to *max_methods* methods on *interface_id* (lazy types).""" + addr = await self._resolve_target_address(address) + methods = [m for m in await self.ensure_method_table(addr) if m.interface_id == interface_id][ + :max_methods + ] + lines: List[str] = [] + for m in methods: + reg = await self._build_minimal_registry_for_signature(addr, m) + lines.append(m.get_signature_string(reg)) + return lines + + async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve Address or dot-path to Address.""" + if isinstance(addr_or_path, str): + return await self.resolve_path(addr_or_path) + return addr_or_path + + async def _walk_node( + self, addr: Address, path: Optional[str], visited: Set[Address] + ) -> Optional[FirmwareTreeNode]: + """Walk one firmware object node, register it, and recurse into children. + + Used by :meth:`_build_firmware_tree` for a full eager DFS. + Pass ``path=None`` to derive the path from the object's own name (root nodes). + Returns ``None`` if *addr* was already visited. """ - self.backend = backend + if addr in visited: + return None + visited.add(addr) + + obj = await self.get_object(addr) + if path is None: + path = obj.name + supported = await self.get_supported_interface0_method_ids(addr) + node = FirmwareTreeNode( + path=path, + address=addr, + object_info=obj, + supported_interface0_methods=supported, + ) + self._registry.register(path, obj) + + # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) + # appears ubiquitous in current PREP captures. + if GET_SUBOBJECT_ADDRESS not in supported: + return node + + for i in range(obj.subobject_count): + try: + sub_addr, sub_obj = await _subobject_address_and_info(self, addr, i) + obj.children[sub_obj.name] = sub_obj + child = await self._walk_node(sub_addr, f"{path}.{sub_obj.name}", visited) + if child is not None: + node.children.append(child) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.debug("walk child failed for %s idx=%d: %s", addr, i, e) + return node + + async def resolve_path(self, path: str) -> Address: + """Resolve a dot-path (e.g. ``"MLPrepRoot.MphRoot.MPH"``) to an :class:`Address`. + + Checks the registry cache first. On a miss, resolves one segment at a time — + enumerating only the children needed at each level — so deep paths on large + firmware trees do not trigger a full tree walk. + Raises :exc:`KeyError` if the path cannot be found. + """ + cached = self._registry.address_for(path) + if cached is not None: + return cached + + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + + root_addr = self._registry.get_root_address() + if root_addr is None: + raise KeyError(f"No root address registered; cannot resolve path '{path}'") + + root_obj = await self.get_object(root_addr) + self._registry.register(root_obj.name, root_obj) + if root_obj.name != parts[0]: + raise KeyError(f"Root object is '{root_obj.name}', not '{parts[0]}'") + if len(parts) == 1: + return root_addr + + current_addr = root_addr + current_path = parts[0] + for part in parts[1:]: + next_path = f"{current_path}.{part}" + cached = self._registry.address_for(next_path) + if cached is not None: + current_addr = cached + current_path = next_path + continue + + obj = await self.get_object(current_addr) + supported = await self.get_supported_interface0_method_ids(current_addr) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"'{current_path}' does not support GetSubobjectAddress; cannot resolve child '{part}'" + ) + + found: Optional[Address] = None + for i in range(obj.subobject_count): + sub_addr, sub_obj = await _subobject_address_and_info(self, current_addr, i) + self._registry.register(f"{current_path}.{sub_obj.name}", sub_obj) + if sub_obj.name == part: + found = sub_addr + + if found is None: + raise KeyError(f"Child '{part}' not found under '{current_path}'") + current_addr = found + current_path = next_path + + return current_addr + + async def _build_firmware_tree(self) -> FirmwareTreeNode: + """Build a DFS firmware tree from the single registered root address.""" + root_addr = self._registry.get_root_address() + if root_addr is None: + raise RuntimeError("Cannot build firmware tree: no root address registered") + + visited: Set[Address] = set() + node = await self._walk_node(root_addr, None, visited) + if node is None: + raise RuntimeError(f"Root node walk returned None for address {root_addr}") + return node + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Return cached firmware tree, or build and cache it when missing.""" + if not refresh and self._firmware_tree_cache is not None: + return self._firmware_tree_cache + + self._firmware_tree_cache = await self._build_firmware_tree() + return self._firmware_tree_cache + + async def get_firmware_tree_flat( + self, refresh: bool = False + ) -> List[Tuple[str, Address, ObjectInfo]]: + """Firmware tree as a flat preorder list of ``(path, address, object_info)``.""" + tree = await self.get_firmware_tree(refresh=refresh) + return flatten_firmware_tree(tree) + + 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. + """ + cached = self._supported_i0_by_address.get(address) + if cached is not None: + return set(cached) + + methods = self._method_table_by_address.get(address) + if methods is None: + obj = await self.get_object(address) + methods = await self.ensure_method_table(address, _object_info=obj) + supported = {m.method_id for m in methods if m.interface_id == 0} + self._supported_i0_by_address[address] = set(supported) + return set(supported) async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -588,13 +1639,15 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) + 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, ) @@ -609,7 +1662,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._send_discovery_command(command) return MethodInfo( interface_id=response["interface_id"], @@ -633,34 +1686,65 @@ 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) - - # 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] - - async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetSubobjectAddressCommand returned None") + + return Address(response.module_id, response.node_id, response.object_id) + + async def get_interfaces( + self, + address: Address, + *, + _supported: Optional[Set[int]] = None, + ) -> List[InterfaceInfo]: """Get available interfaces. + The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and + interface_names (STRING_ARRAY). Returns [] if the object does not support + GetInterfaces (interface 0, method 4). + Args: address: Object address + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: List of interface information """ + if _supported is None: + _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) + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") - return [ + ids = list(response.interface_ids) + names = list(response.interface_names) + infos = [ InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=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)) ] + self._interfaces_by_address[address] = infos + return infos 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 @@ -669,16 +1753,58 @@ 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) - - return [ - EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) - for enum_def in response["enums"] - ] + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetEnumsCommand returned None") + + 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._send_query(command) + if result is None: + raise RuntimeError("GetStructs query returned no data.") + (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 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 interface_id: Interface ID @@ -687,146 +1813,233 @@ 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) - - return [ - StructInfo( - struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] - ) - for struct_def in response["structs"] - ] - - async def get_all_methods(self, address: Address) -> List[MethodInfo]: - """Get all methods for an object. - - 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) - - methods = [] - for i in range(object_info.method_count): - try: - method = await self.get_method(address, i) - methods.append(method) - except Exception as e: - logger.warning(f"Failed to get method {i} for {address}: {e}") - - return methods - - async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: - """Recursively discover object hierarchy. + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetStructsCommand returned None") + + struct_names = list(response.struct_names) + # 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; entries are 1, 3, or 7 bytes wide + field_names = list(response.field_names) + n_structs = len(field_counts) + if n_structs == 0: + return [] + + # Walk type_bytes with a byte-level cursor. Width varies: 1=simple, 3=ref (source_id 1–3), + # 7=node-global (source_id=4). field_counts gives logical field count per struct, + # not bytes — _parse_struct_field_types tracks exact byte consumption via _byte_width. + 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}" + parsed = _parse_struct_field_types(type_bytes[byte_offset:]) + # 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 build_type_registry( + self, + address: Union[Address, str], + global_pool: Optional[GlobalTypePool] = None, + *, + _supported: Optional[Set[int]] = None, + ) -> 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. + Only calls Interface 0 methods that the object supports; skips unsupported + commands and builds a partial registry. Args: - root_address: Root object address + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + uses :meth:`ensure_global_type_pool` (same as :meth:`_build_minimal_registry_for_signature`). + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: - Nested dictionary of discovered objects + TypeRegistry with all type information for this object """ - hierarchy = {} - - 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] - - # Discover subobjects - subobjects = {} - for i in range(root_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}") + address = await self._resolve_target_address(address) + if global_pool is None: + global_pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=address, global_pool=global_pool) + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + + if GET_INTERFACES in _supported: + interfaces = await self.get_interfaces(address, _supported=_supported) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + else: + interfaces = [] - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] + if GET_METHOD in _supported: + registry.methods = await self.ensure_method_table(address) + else: + registry.methods = [] - # 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] + for iface in interfaces: + if GET_STRUCTS in _supported or GET_ENUMS in _supported: + await self.ensure_structs_enums(address, iface.interface_id) + self._attach_iface_types_to_registry(registry, address, iface.interface_id) - 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] + return registry - return hierarchy + 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. - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. + 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 MethodParamType.resolve_name() can find them. Args: - root_addresses: List of root addresses to start discovery from + 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. If omitted, + :meth:`build_type_registry` attaches the session pool automatically. Returns: - Dictionary mapping address strings to discovered hierarchies + TypeRegistry that can resolve types from both parent and children. """ - all_objects = {} + address = await self._resolve_target_address(address) + supported = await self.get_supported_interface0_method_ids(address) + registry = await self.build_type_registry( + address, global_pool=global_pool, _supported=supported + ) - for root_address in root_addresses: + if subobject_addresses is None: + 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 _TRANSIENT_ERRORS: + raise + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) + + for sub_addr in subobject_addresses: try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy + 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 _TRANSIENT_ERRORS: + raise except Exception as e: - logger.error(f"Failed to discover objects from {root_address}: {e}") - all_objects[str(root_address)] = {"error": str(e)} - - return all_objects + logger.debug("build_type_registry failed for child %s: %s", sub_addr, e) - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. + return registry - Args: - methods: List of MethodInfo objects to print - """ - 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() + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build a fresh global type pool from *global_addresses* (full walk; not the session singleton). - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. + Mirrors piglet: walk each global object, iterate interfaces, collect structs/enums in + encounter order for ``source_id=1`` lookups. For lazy signature resolution on a live + session, use :meth:`ensure_global_type_pool` so the pool is built once and reused. Args: - structs: List of StructInfo objects to print + global_addresses: List of global object addresses + (from :attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.global_object_addresses`). + + Returns: + GlobalTypePool with all global structs and enums. """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() + return await self._build_global_type_pool_impl(global_addresses) - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. + async def get_method_by_id( + self, + 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). + + 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: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) + 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: - List of methods matching the name pattern + MethodInfo for the matching method, or None if not found. """ - return [method for method in methods if name_pattern.lower() in method.name.lower()] - - def get_methods_by_interface( - self, methods: List[MethodInfo], interface_id: int - ) -> List[MethodInfo]: - """Filter methods by interface ID. - - Args: - methods: List of MethodInfo objects to filter - interface_id: Interface ID to filter by + if registry is not None: + cached = registry.get_method(interface_id, method_id) + if cached is not None: + return cached + address = await self._resolve_target_address(address) + methods = await self.ensure_method_table(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: + """Return a fully resolved method signature string. + + When *registry* is omitted, loads only the + method table, global pool, and structs/enums needed for this signature (no full + :meth:`build_type_registry`). Pass an explicit *registry* for export/golden parity. + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... Returns: - List of methods from the specified interface + Human-readable signature string, or a descriptive error string. """ - return [method for method in methods if method.interface_id == interface_id] + address = await self._resolve_target_address(address) + if registry is not None: + method = await self.get_method_by_id(address, interface_id, method_id, registry=registry) + if method is None: + return f"" + return method.get_signature_string(registry) + methods = await self.ensure_method_table(address) + method = next( + (m for m in methods if m.interface_id == interface_id and m.method_id == method_id), + None, + ) + if method is None: + return f"" + reg = await self._build_minimal_registry_for_signature(address, method) + return method.get_signature_string(reg) diff --git a/pylabrobot/hamilton/transport/tcp/messages.py b/pylabrobot/hamilton/transport/tcp/messages.py index ea1952b245c..0cb4e69d294 100644 --- a/pylabrobot/hamilton/transport/tcp/messages.py +++ b/pylabrobot/hamilton/transport/tcp/messages.py @@ -1,42 +1,28 @@ -"""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). + +STATUS/COMMAND exception param parsing and :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError` +live in :mod:`pylabrobot.hamilton.transport.tcp.hoi_error`. """ from __future__ import annotations +import logging from dataclasses import dataclass -from typing import Any +from dataclasses import fields as dc_fields +from typing import Any, List, cast, get_args, get_origin, get_type_hints +from pylabrobot.hamilton.transport.tcp.hoi_error import parse_hc_results_from_semicolon_string from pylabrobot.hamilton.transport.tcp.packets import ( Address, HarpPacket, @@ -45,12 +31,20 @@ RegistrationPacket, ) from pylabrobot.hamilton.transport.tcp.protocol import ( - HamiltonDataType, HarpTransportableProtocol, + Hoi2Action, RegistrationOptionType, ) +from pylabrobot.hamilton.transport.tcp.wire_types import ( + HcResultEntry, + decode_fragment, +) from pylabrobot.io.binary import Reader, Writer +PADDED_FLAG = 0x01 + +logger = logging.getLogger(__name__) + # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol # ============================================================================ @@ -75,9 +69,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()) """ @@ -89,200 +83,122 @@ 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 - # 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 add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" - data = Writer().u8(value).finish() - return self._add_fragment(HamiltonDataType.U8, data) + wire_type may be a WireType instance or an Annotated alias (e.g. I32, Str). + """ + if hasattr(wire_type, "__metadata__"): + wire_type = wire_type.__metadata__[0] + return cast("HoiParams", wire_type.encode_into(value, self)) - def u16(self, value: int) -> "HoiParams": - """Add unsigned 16-bit integer parameter.""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.U16, data) + # ------------------------------------------------------------------ + # Ergonomic shims — each delegates to add() with the matching alias + # from wire_types. No encoding logic lives here. + # ------------------------------------------------------------------ - 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 i8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I8 - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) + return self.add(value, I8) - # 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 i16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I16 - 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) -> "HoiParams": - """Add boolean parameter.""" - data = Writer().u8(1 if value else 0).finish() - return self._add_fragment(HamiltonDataType.BOOL, data) - - # 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) - """ - writer = Writer() - for val in values: - writer.i8(val) - return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) + return self.add(value, I16) - def i16_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 16-bit integers. + def i32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I32 - 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()) + return self.add(value, I32) - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. + def i64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I64 - 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()) + return self.add(value, I64) - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. + def u8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U8 - 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()) + return self.add(value, U8) - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. + def u16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U16 - 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()) + return self.add(value, U16) - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. + def u32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U32 - 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()) + return self.add(value, U32) - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. + def u64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U64 - 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()) + return self.add(value, U64) - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. + def f32(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import F32 - 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()) + return self.add(value, F32) - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. + def f64(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import F64 - 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()) + return self.add(value, F64) - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. + def bool_(self, value: bool) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import Bool - 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()) + return self.add(value, Bool) - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). + def str_(self, value: str) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import Str - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + return self.add(value, Str) - 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) + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. - Format: [count:4][str0\0][str1\0]... + 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. """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + from dataclasses import fields as dc_fields + from typing import get_type_hints + + from pylabrobot.hamilton.transport.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 cast("HoiParams", params) def build(self) -> bytes: """Return concatenated DataFragments.""" @@ -294,162 +210,301 @@ 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): + 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 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 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)}" + ) + 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 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] + 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 {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 - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # 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: [count:4][str0\0][str1\0]... - count = Reader(data[:4]).u32() - strings = [] - offset = 4 - for _ in range(count): - end = data.index(0, offset) - strings.append(data[offset:end].decode("utf-8", errors="replace")) - offset = end + 1 - 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}") + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload 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. + def remaining(self) -> bytes: + """Unconsumed payload bytes (from current cursor to end).""" + return self._data[self._offset :] - Returns: - List of (type_id, value) tuples - """ + def skip_next(self) -> None: + """Advance past one DataFragment without decoding the payload.""" + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {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 {payload_end}, have {len(self._data)}" + ) + self._offset = payload_end + + def parse_all(self) -> list[tuple[int, Any]]: results = [] while self.has_remaining(): results.append(self.parse_next()) 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 hoi_action_code_base(action_byte: int) -> int: + """Lower 4 bits of HOI action field (response-required bit is 0x10).""" + return action_byte & 0x0F + + +def split_hoi_params_after_warning_prefix( + action_code: int, params: bytes +) -> tuple[bytes, list[HcResultEntry]]: + """If action is StatusWarning/CommandWarning, drop the first two fragments and parse the string aggregate. + + Mirrors ``SystemController.SendAndReceive``: out-parameters start at fragment index 2; fragment 1 holds + the formatted warning list consumed by ``HoiResult(HoiPacket2)`` / ``GetHcResults``. + """ + if not params: + return params, [] + base = hoi_action_code_base(action_code) + if base not in (Hoi2Action.STATUS_WARNING, Hoi2Action.COMMAND_WARNING): + return params, [] + + parser = HoiParamsParser(params) + if not parser.has_remaining(): + return params, [] + try: + _tid0, _v0 = parser.parse_next() + if not parser.has_remaining(): + return params, [] + _tid1, v1 = parser.parse_next() + except ValueError: + return params, [] + + rest = parser.remaining() + prefix_entries: list[HcResultEntry] = [] + if isinstance(v1, str): + prefix_entries = parse_hc_results_from_semicolon_string(v1) + elif isinstance(v1, (bytes, bytearray)): + prefix_entries = parse_hc_results_from_semicolon_string( + bytes(v1).decode("utf-8", errors="replace") + ) + return rest, prefix_entries + + +def log_hoi_result_entries(command_name: str, entries: list[HcResultEntry], *, source: str) -> None: + """Log non-success ``HcResultEntry`` rows (0x0000 skipped).""" + for entry in entries: + if entry.result == 0: + continue + logger.warning( + "%s %s channel result at %d:%d:%d iface=%d action=%d: 0x%04X (%s)", + command_name, + source, + entry.module_id, + entry.node_id, + entry.object_id, + entry.interface_id, + entry.action_id, + entry.result, + "warning" if entry.is_warning else "error", + ) + + +def interpret_hoi_success_payload(command: Any, params_bytes: bytes) -> Any: + """Decode command ``Response`` from HOI params. + + Used for CommandResponse / StatusResponse payloads after exception and + warning-prefix handling. Success frames carry only the fields declared in + the Response dataclass — no HoiResult trailer (see firmware yaml dumps and + protocol decoder behavior; HoiResult only rides on warning-prefix or exception + frames). + """ + cls = type(command) + if not params_bytes: + return None + + if hasattr(cls, "Response"): + return parse_into_struct(HoiParamsParser(params_bytes), cls.Response) + + return command.parse_response_parameters(params_bytes) + + +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.hamilton.transport.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): + _, raw = parser.parse_next() + element_type = get_args(get_args(ann)[0])[0] + 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() + + 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 # ============================================================================ @@ -681,10 +736,10 @@ def build(self) -> bytes: Returns: Complete packet bytes ready to send over TCP """ - # Build raw connection parameters (NOT DataFragments) + # Build raw Protocol-7 connection blob (NOT DataFragments — distinct from HoiParams). # Frame: [version:1][message_id:1][count:1][unknown:1] # Parameters: [id:1][type:1][reserved:2][value:2] repeated - params = ( + connection_blob = ( Writer() # Frame .u8(0) # version @@ -710,7 +765,7 @@ def build(self) -> bytes: ) # Build IP packet - packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + packet_size = 1 + 1 + 2 + len(connection_blob) # protocol + version + opts_len + blob return ( Writer() @@ -718,7 +773,7 @@ def build(self) -> bytes: .u8(self.ip_protocol) .u8(self.protocol_version) .u16(0) # options_length - .raw_bytes(params) + .raw_bytes(connection_blob) .finish() ) diff --git a/pylabrobot/hamilton/transport/tcp/packets.py b/pylabrobot/hamilton/transport/tcp/packets.py index 42d308f1c48..fb301cfbef6 100644 --- a/pylabrobot/hamilton/transport/tcp/packets.py +++ b/pylabrobot/hamilton/transport/tcp/packets.py @@ -12,14 +12,11 @@ from __future__ import annotations -import logging import struct from dataclasses import dataclass from pylabrobot.io.binary import Reader, Writer -logger = logging.getLogger(__name__) - # Hamilton protocol version HAMILTON_PROTOCOL_VERSION_MAJOR = 3 HAMILTON_PROTOCOL_VERSION_MINOR = 0 @@ -40,14 +37,14 @@ def encode_version_byte(major: int, minor: int) -> int: return version_byte -def decode_version_byte(version_byte: int) -> tuple[int, int]: +def decode_version_byte(version_bite: int) -> tuple[int, int]: """Decode Hamilton version byte and return (major, minor). Returns: Tuple of (major_version, minor_version), each 0-15 """ - minor = version_byte & 0xF - major = (version_byte >> 4) & 0xF + minor = version_bite & 0xF + major = (version_bite >> 4) & 0xF return (major, minor) @@ -116,13 +113,8 @@ def unpack(cls, data: bytes) -> "IpPacket": # Validate version if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - logger.warning( - "Hamilton protocol version mismatch: expected %d.%d, got %d.%d", - HAMILTON_PROTOCOL_VERSION_MAJOR, - HAMILTON_PROTOCOL_VERSION_MINOR, - major, - minor, - ) + # Warning but not fatal + pass opts_len = r.u16() options = r.raw_bytes(opts_len) if opts_len > 0 else b"" diff --git a/pylabrobot/hamilton/transport/tcp/protocol.py b/pylabrobot/hamilton/transport/tcp/protocol.py index 9e916e91db3..60b89b61dee 100644 --- a/pylabrobot/hamilton/transport/tcp/protocol.py +++ b/pylabrobot/hamilton/transport/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,49 +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 - - # 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/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 7c31e56c291..6d1d8c46ad3 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -1,13 +1,27 @@ -"""Hamilton TCP Handler base class for TCP-based instruments (Nimbus, Prep, etc.).""" +"""Hamilton TCP client for TCP-based instruments (Nimbus, Prep, etc.). + +Use :attr:`HamiltonTCPClient.introspection` as the **only** supported entry for +Interface-0 discovery and type work. +""" from __future__ import annotations import asyncio import logging -from dataclasses import dataclass -from typing import Dict, Optional, Union - -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand, hamilton_error_for_entry +from pylabrobot.hamilton.transport.tcp.error_tables import HC_RESULT_PROTOCOL +from pylabrobot.hamilton.transport.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_params, +) +from pylabrobot.hamilton.transport.tcp.introspection import ( + HamiltonIntrospection, + MethodDescriptor, + ObjectRegistry, +) from pylabrobot.hamilton.transport.tcp.messages import ( CommandResponse, InitMessage, @@ -22,81 +36,29 @@ RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError logger = logging.getLogger(__name__) -@dataclass -class HamiltonError: - """Hamilton error response.""" - - error_code: int - error_message: str - interface_id: int - action_id: int - - -class ErrorParser: - """Parse Hamilton error responses.""" - - @staticmethod - def parse_error(data: bytes) -> HamiltonError: - """Parse error response from Hamilton instrument.""" - # Error responses have a specific format - # This is a simplified implementation - real errors may vary - if len(data) < 8: - raise ValueError("Error response too short") +class HamiltonTCPClient: + """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" - # Parse error structure (simplified) - error_code = Reader(data).u32() - error_message = data[4:].decode("utf-8", errors="replace") - - return HamiltonError( - error_code=error_code, error_message=error_message, interface_id=0, action_id=0 - ) - - -class HamiltonTCPHandler: - """Base driver 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 - - Hamilton uses strict request-response protocol (no unsolicited messages), - so we use simple direct read/write instead of complex routing. - """ + _ERROR_CODES: ClassVar[Dict[Tuple[int, int, int, int, int], str]] = {} def __init__( self, host: str, port: int, - read_timeout: float = 30.0, + read_timeout: float = 300.0, write_timeout: float = 30.0, auto_reconnect: bool = True, max_reconnect_attempts: int = 3, + connection_timeout: int = 600, ): - """Initialize Hamilton TCP handler. - - Args: - host: Hamilton instrument IP address - port: Hamilton instrument port - 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( human_readable_device_name="Hamilton Liquid Handler", host=host, @@ -105,23 +67,128 @@ def __init__( 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 + self._connection_timeout = connection_timeout - # Hamilton-specific state 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._global_object_addresses: list[Address] = [] + self._event_handlers: list[Callable[[CommandResponse], None]] = [] + self._introspection_impl: Optional[HamiltonIntrospection] = None + + @property + def registry(self) -> ObjectRegistry: + """Object path registry for this session.""" + return self._registry + + @property + def global_object_addresses(self) -> Sequence[Address]: + """Global object addresses discovered during :meth:`setup` (read-only).""" + return tuple(self._global_object_addresses) + + def get_root_object_addresses(self) -> list[Address]: + """Root address from the registry as a single-element list.""" + addr = self._registry.get_root_address() + return [addr] if addr is not None else [] + + @property + def introspection(self) -> HamiltonIntrospection: + """Lazy Interface-0 / type introspection facet (canonical entry).""" + if self._introspection_impl is None: + self._introspection_impl = HamiltonIntrospection( + registry=self._registry, + global_object_addresses=self._global_object_addresses, + send_discovery_command=self.send_discovery_command, + send_query=self.send_query, + ) + return self._introspection_impl + + def _invalidate_introspection_session(self) -> None: + self._introspection_impl = None + + async def _describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: + """Resolve an HcResultEntry to (interface_name, description) for error reporting.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + iface_name = await self.introspection.get_interface_name(addr, entry.interface_id) + # Vendor tables key on (module, node, object_id, interface_id, hc_result). The wire + # action_id is the failing method id and must not be used for that slot — otherwise + # we miss lookups and show raw HC_RESULT=0x.... instead of "No Tip Picked Up." / etc. + key_iface = (entry.module_id, entry.node_id, entry.object_id, entry.interface_id, entry.result) + key_action = (entry.module_id, entry.node_id, entry.object_id, entry.action_id, entry.result) + desc = self._ERROR_CODES.get(key_iface) + if desc is None and key_action != key_iface: + desc = self._ERROR_CODES.get(key_action) + if desc is None: + desc = HC_RESULT_PROTOCOL.get(entry.result) + if desc is None: + desc = await self.introspection.get_hc_result_text(addr, entry.interface_id, entry.result) + if desc is None: + desc = f"HC_RESULT=0x{entry.result:04X}" + return iface_name, desc + + async def _format_entry_context(self, entry: HcResultEntry) -> Optional[str]: + """Resolve an HcResultEntry to a human-readable method context string.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + path = self._registry.path(addr) + path_part = f"path={path}" if path else "path=?" + descriptor = await self._lookup_method_descriptor(addr, entry.interface_id, entry.action_id) + if descriptor is None: + return f"{path_part}, addr={addr}, iface={entry.interface_id}, action={entry.action_id}" + return ( + f"{path_part}, addr={addr}, method={descriptor.id_string} {descriptor.signature_string()}" + ) + + async def _lookup_method_descriptor( + self, addr: Address, interface_id: int, action_id: int + ) -> Optional[MethodDescriptor]: + try: + method = await self.introspection.get_method_by_id(addr, interface_id, action_id) + if method is None: + return None + return method.describe(None) + except Exception as exc: + logger.debug( + "Method descriptor lookup failed for %s iface=%d action=%d: %s", + addr, + interface_id, + action_id, + exc, + ) + return None + + def on_event(self, callback: Callable[[CommandResponse], None]) -> Callable[[], None]: + """Register a callback for ``Hoi2Action.EVENT`` frames. + + Returns an unsubscribe function. Callback exceptions are logged and swallowed. + """ + self._event_handlers.append(callback) + + def _unsubscribe() -> None: + try: + self._event_handlers.remove(callback) + except ValueError: + pass + + return _unsubscribe + + def _dispatch_event(self, response_message: CommandResponse) -> None: + for handler in list(self._event_handlers): + try: + handler(response_message) + except Exception as exc: + logger.exception("Event handler %r raised: %s", handler, exc) + + def _clear_session_state_for_setup(self) -> None: + self._global_object_addresses = [] + self._invalidate_introspection_session() async def _ensure_connected(self): - """Ensure connection is healthy before operations.""" if not self._connected: if not self.auto_reconnect: raise ConnectionError( @@ -131,7 +198,6 @@ async def _ensure_connected(self): await self._reconnect() async def _reconnect(self): - """Attempt to reconnect with exponential backoff.""" if not self.auto_reconnect: raise ConnectionError(f"{self.io._unique_id} Auto-reconnect disabled") @@ -141,18 +207,15 @@ async def _reconnect(self): f"{self.io._unique_id} Reconnection attempt {attempt + 1}/{self.max_reconnect_attempts}" ) - # Clean up existing connection try: await self.stop() except Exception: pass - # Wait before reconnecting (exponential backoff) if attempt > 0: - wait_time = 1.0 * (2 ** (attempt - 1)) # 1s, 2s, 4s, etc. + wait_time = 1.0 * (2 ** (attempt - 1)) await asyncio.sleep(wait_time) - # Attempt to reconnect await self.setup() self._reconnect_attempts = 0 logger.info(f"{self.io._unique_id} Reconnection successful") @@ -161,19 +224,12 @@ async def _reconnect(self): except Exception as e: logger.warning(f"{self.io._unique_id} Reconnection attempt {attempt + 1} failed: {e}") - # All reconnection attempts failed self._connected = False raise ConnectionError( f"{self.io._unique_id} Failed to reconnect after {self.max_reconnect_attempts} attempts" ) async def write(self, data: bytes, timeout: Optional[float] = None): - """Write data to the socket with connection state tracking. - - Args: - data: The data to write. - timeout: The timeout for writing to the server in seconds. If `None`, use the default timeout. - """ await self._ensure_connected() try: @@ -184,103 +240,54 @@ async def write(self, data: bytes, timeout: Optional[float] = None): raise async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> bytes: - """Read data from the socket with connection state tracking. - - Args: - num_bytes: Maximum number of bytes to read. Defaults to 128. - timeout: The timeout for reading from the server in seconds. If `None`, use the default - timeout. - - Returns: - The data read from the socket. - """ await self._ensure_connected() try: data = await self.io.read(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: - """Read exactly num_bytes with connection state tracking. - - Args: - num_bytes: The exact number of bytes to read. - timeout: The timeout for reading from the server in seconds. If `None`, use the default - timeout. - - Returns: - Exactly num_bytes of data. - - Raises: - ConnectionError: If the connection is closed before num_bytes are read. - """ await self._ensure_connected() try: data = await self.io.read_exact(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise @property def is_connected(self) -> bool: - """Check if the connection is currently established.""" return self._connected - async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: - """Read one complete Hamilton packet and parse based on protocol. - - Hamilton packets are length-prefixed: - - First 2 bytes: packet size (little-endian) - - Next packet_size bytes: packet payload - - The method inspects the IP protocol field and, for Protocol 6 (HARP), - also checks the HARP protocol field to dispatch correctly. - - Returns: - Union[RegistrationResponse, CommandResponse]: Parsed response - - Raises: - ConnectionError: If connection is lost - TimeoutError: If no message received within timeout - ValueError: If protocol type is unknown - """ - - # Read packet size (2 bytes, little-endian) - size_data = await self.read_exact(2) + async def _read_one_message( + self, timeout: Optional[float] = None + ) -> Union[RegistrationResponse, CommandResponse]: + 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) - # Format: [size:2][ip_protocol:1][version:1][options_len:2][options:x][payload:n] ip_protocol = complete_data[2] - # Dispatch based on IP protocol if ip_protocol == 6: - # Protocol 6: HARP wrapper - need to check HARP protocol field - # IP header: [size:2][protocol:1][version:1][options_len:2] ip_options_len = int.from_bytes(complete_data[4:6], "little") harp_start = 6 + ip_options_len - - # HARP header: [src:6][dst:6][seq:1][unk:1][harp_protocol:1][action:1]... - # HARP protocol is at offset 14 within HARP packet harp_protocol_offset = harp_start + 14 harp_protocol = complete_data[harp_protocol_offset] if harp_protocol == 2: - # HARP Protocol 2: HOI2 - return CommandResponse.from_bytes(complete_data) + resp = CommandResponse.from_bytes(complete_data) + if resp.hoi.action_code == Hoi2Action.EVENT and self._event_handlers: + self._dispatch_event(resp) + return resp if harp_protocol == 3: - # HARP Protocol 3: Registration2 return RegistrationResponse.from_bytes(complete_data) logger.warning(f"Unknown HARP protocol: {harp_protocol}, attempting CommandResponse parse") return CommandResponse.from_bytes(complete_data) @@ -289,125 +296,70 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse return CommandResponse.from_bytes(complete_data) async def setup(self): - """Initialize Hamilton connection and discover objects. - - Hamilton uses strict request-response protocol: - 1. Establish TCP connection - 2. Protocol 7 initialization (get client ID) - 3. Protocol 3 registration - 4. Discover objects via Protocol 3 introspection - """ - - # Step 1: Establish TCP connection + self._clear_session_state_for_setup() await self.io.setup() - - # Set connection state after successful connection self._connected = True self._reconnect_attempts = 0 - - # Step 2: Initialize connection (Protocol 7) await self._initialize_connection() - - # Step 3: Register client (Protocol 3) await self._register_client() - - # Step 4: Discover root objects await self._discover_root() - - logger.info(f"Hamilton handler setup complete. Client ID: {self._client_id}") + await self._discover_globals() + + root_addr = self._registry.get_root_address() + if root_addr is not None: + root_info = await self.introspection.get_object(root_addr) + root_info.children = {} + self._registry.register(root_info.name, root_info) + + logger.info( + "Hamilton TCP client setup complete. Client ID: %s, globals: %d", + self._client_id, + len(self._global_object_addresses), + ) async def _initialize_connection(self): - """Initialize connection using Protocol 7 (ConnectionPacket). - - Note: Protocol 7 doesn't have sequence numbers, so we send the packet - and read the response directly (blocking) rather than using the - normal routing mechanism. - """ logger.info("Initializing Hamilton connection...") - # Build Protocol 7 ConnectionPacket using new InitMessage - packet = InitMessage(timeout=30).build() - - logger.info("[INIT] Sending Protocol 7 initialization packet:") - logger.info(f"[INIT] Length: {len(packet)} bytes") - logger.info(f"[INIT] Hex: {packet.hex(' ')}") - - # Send packet + packet = InitMessage(timeout=self._connection_timeout).build() await self.write(packet) - # Read response directly (blocking - safe because this is first communication) - # Read packet size (2 bytes, little-endian) size_data = await self.read_exact(2) packet_size = Reader(size_data).u16() - - # Read packet payload 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(' ')}") - - # Parse response using InitResponse response = InitResponse.from_bytes(response_bytes) self._client_id = response.client_id - # 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}") - async def _register_client(self): - """Register client using Protocol 3.""" logger.info("Registering Hamilton client...") - - # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) registration_service = Address(0, 0, 65534) - # Step 1: Initial registration (action_code=0) reg_msg = RegistrationMessage( dest=registration_service, action_code=RegistrationActionCode.REGISTRATION_REQUEST ) - # Ensure client is initialized if self.client_address is None or self._client_id is None: raise RuntimeError("Client not initialized - call _initialize_connection() first") - # Build and send registration packet seq = self._allocate_sequence_number(registration_service) packet = reg_msg.build( src=self.client_address, - req_addr=Address(2, self._client_id, 65535), # C# DLL: 2:{client_id}:65535 - res_addr=Address(0, 0, 0), # C# DLL: 0:0:0 + req_addr=Address(2, self._client_id, 65535), + res_addr=Address(0, 0, 0), seq=seq, - harp_action_code=3, # COMMAND_REQUEST - harp_response_required=False, # DLL uses 0x03 (no response flag) + harp_action_code=3, + harp_response_required=False, ) - 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}") - - # Send registration packet await self.write(packet) - - # 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.info("[REGISTER] Registration complete") + await self._read_one_message() async def _discover_root(self): - """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" logger.info("Discovering Hamilton root objects...") registration_service = Address(0, 0, 65534) - - # Request root objects (request_id=1) root_msg = RegistrationMessage( dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST ) @@ -417,7 +369,6 @@ async def _discover_root(self): request_id=HoiRequestId.ROOT_OBJECT_OBJECT_ID, ) - # Ensure client is initialized if self.client_address is None or self._client_id is None: raise RuntimeError("Client not initialized - call _initialize_connection() first") @@ -427,44 +378,52 @@ async def _discover_root(self): req_addr=Address(0, 0, 0), res_addr=Address(0, 0, 0), seq=seq, - harp_action_code=3, # COMMAND_REQUEST - harp_response_required=True, # Request with response + harp_action_code=3, + harp_response_required=True, ) - 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(' ')}") - - # Send request await self.write(packet) - - # Read response response = await self._read_one_message() assert isinstance(response, RegistrationResponse) - logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} 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") - - # Store discovered root objects - self._discovered_objects["root"] = root_objects - - logger.info(f"Discovery complete: {len(root_objects)} root objects") + if len(root_objects) != 1: + raise RuntimeError( + f"Expected exactly one root object from discovery, got {len(root_objects)}: {root_objects}" + ) + self._registry.set_root_address(root_objects[0]) + + async def _discover_globals(self) -> None: + 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, + ) - def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: - """Parse registration response options to extract object addresses. + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") - From Piglet: Option type 6 (HARP_PROTOCOL_RESPONSE) contains object IDs - as a packed list of u16 values. + 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, + harp_response_required=True, + ) - Args: - response: Parsed RegistrationResponse + await self.write(packet) + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + self._global_object_addresses = self._parse_registration_response(response) - Returns: - List of discovered object addresses - """ + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: objects: list[Address] = [] options_data = response.registration.options @@ -472,113 +431,297 @@ def _parse_registration_response(self, response: RegistrationResponse) -> list[A logger.debug("No options in registration response (no objects found)") return objects - # Parse options: [option_id:1][length:1][data:x] reader = Reader(options_data) - while reader.has_remaining(): option_id = reader.u8() length = reader.u8() if option_id == RegistrationOptionType.HARP_PROTOCOL_RESPONSE: if length > 0: - # Skip padding u16 _ = reader.u16() - - # Read object IDs (u16 each) num_objects = (length - 2) // 2 for _ in range(num_objects): object_id = reader.u16() - # Objects are at Address(1, 1, object_id) objects.append(Address(1, 1, object_id)) else: logger.warning(f"Unknown registration option ID: {option_id}, skipping {length} bytes") - # Skip unknown option data reader.raw_bytes(length) return objects def _allocate_sequence_number(self, dest_address: Address) -> int: - """Allocate next sequence number for destination. - - Args: - dest_address: Destination object address - - Returns: - Next sequence number for this destination - """ current = self._sequence_numbers.get(dest_address, 0) - next_seq = (current + 1) % 256 # Wrap at 8 bits (1 byte) + next_seq = (current + 1) % 256 self._sequence_numbers[dest_address] = next_seq return next_seq - async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: - """Send Hamilton command and wait for response. + async def send_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send a command and return the interpreted response. Raises on any firmware error.""" + return await self._send_raw( + command, + ensure_connection=True, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def send_query( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Optional[tuple]: + """Send a read/status command and return raw HOI bytes. Returns None on firmware error. + + Use for hardware state probing where the response needs manual parsing or where + the firmware path may legitimately return an error (e.g. tip-presence checks). + Follows SCPI convention: queries read state, commands change state. + """ + return cast( + Optional[tuple], + await self._send_raw( + command, + ensure_connection=True, + return_raw=True, + raise_on_error=False, + read_timeout=read_timeout, + ), + ) + + async def send_discovery_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send an Interface-0 introspection command during setup (no reconnect on failure).""" + return await self._send_raw( + command, + ensure_connection=False, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + connection_errors = ( + BrokenPipeError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + OSError, + ) + max_attempts = 2 if ensure_connection else 1 + last_error: Optional[BaseException] = None - Sets source_address if not already set by caller (for testing). - Uses handler's client_address assigned during Protocol 7 initialization. + for attempt in range(max_attempts): + try: + if command.source_address is None: + if self.client_address is None: + raise RuntimeError( + "Client 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) + + while True: + response_message = await self._read_one_message(timeout=read_timeout) + assert isinstance(response_message, CommandResponse) + action = Hoi2Action(response_message.hoi.action_code) + if action is Hoi2Action.COMMAND_ACK: + logger.debug( + "%s COMMAND_ACK from %s; awaiting terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + if action is Hoi2Action.EVENT: + logger.debug( + "%s EVENT from %s; skipping past to await terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + break + + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + entries = parse_hamilton_error_entries(response_message.hoi.params) + if not entries: + raw = parse_hamilton_error_params(response_message.hoi.params) + enriched_msg = f"Hamilton error {action.name} (action={action:#x}): {raw}" + if raise_on_error: + logger.error(enriched_msg) + raise RuntimeError(enriched_msg) + logger.debug(enriched_msg) + return None + + if command.error_entries_use_physical_channels(): + per_channel: Dict[int, Exception] = {} + context_by_channel: Dict[int, Optional[str]] = {} + hoi_exceptions: Dict[int, Exception] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + hoi_exceptions[idx] = err + channel = command._channel_index_for_entry(idx, entry) + if channel is None: + channel = idx + per_channel.setdefault(channel, err) + if channel not in context_by_channel: + context_by_channel[channel] = await self._format_entry_context(entry) + + if raise_on_error: + channel_summary = ", ".join( + ( + f"ch{ch}: {per_channel[ch]} ({context_by_channel[ch]})" + if context_by_channel.get(ch) + else f"ch{ch}: {per_channel[ch]}" + ) + for ch in sorted(per_channel) + ) + logger.error( + "Hamilton %s (action=%#x) on %d channel(s): %s", + action.name, + action, + len(per_channel), + channel_summary, + ) + raise ChannelizedError( + errors=per_channel, + raw_response=response_message.hoi.params, + hoi_entries=list(entries), + hoi_exceptions=hoi_exceptions, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + entry_errors: Dict[int, Exception] = {} + context_by_idx: Dict[int, Optional[str]] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + entry_errors[idx] = err + context_by_idx[idx] = await self._format_entry_context(entry) + + if raise_on_error: + summary = ", ".join( + ( + f"entry[{idx}]: {entry_errors[idx]} ({context_by_idx[idx]})" + if context_by_idx.get(idx) + else f"entry[{idx}]: {entry_errors[idx]}" + ) + for idx in sorted(entry_errors) + ) + logger.error( + "Hamilton %s (action=%#x), instrument-wide error (%d entries): %s", + action.name, + action, + len(entries), + summary, + ) + raise HoiError( + exceptions=entry_errors, + entries=list(entries), + raw_response=response_message.hoi.params, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + if return_raw: + return (response_message.hoi.params,) + + result = command.interpret_response(response_message) + fatal = command.fatal_entries_by_channel(response_message) + if fatal: + fatal_per_channel: Dict[int, Exception] = {} + fatal_context_by_channel: Dict[int, Optional[str]] = {} + for ch, e in fatal.items(): + _iface_name, desc = await self._describe_entry(e) + fatal_per_channel[ch] = hamilton_error_for_entry(e, desc) + fatal_context_by_channel[ch] = await self._format_entry_context(e) + logger.error( + "Hamilton command fatal entries: %s", + ", ".join( + ( + f"ch{ch}: {fatal_per_channel[ch]} ({fatal_context_by_channel[ch]})" + if fatal_context_by_channel.get(ch) + else f"ch{ch}: {fatal_per_channel[ch]}" + ) + for ch in sorted(fatal_per_channel) + ), + ) + raise ChannelizedError(errors=fatal_per_channel, raw_response=response_message.hoi.params) + return result + + 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() - Args: - command: Hamilton command to execute - timeout: Maximum time to wait for response + assert last_error is not None + raise last_error - Returns: - Parsed response dictionary, or None if command has no information to extract + async def resolve_path(self, path: str) -> Address: + """Resolve dot-path to Address (delegates to introspection).""" + return await self.introspection.resolve_path(path) - Raises: - TimeoutError: If no response received within timeout - HamiltonError: If command returned an error - """ - # Set source address with smart fallback - if command.source_address is None: - if self.client_address is None: - raise RuntimeError("Handler 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 for debugging - 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}") - - # Send command - await self.write(message) - - # Read response, honoring the per-call timeout when provided. - if timeout is None: - response_message = await self._read_one_message() - else: - response_message = await asyncio.wait_for(self._read_one_message(), timeout) - assert isinstance(response_message, CommandResponse) - - # 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}") - - return command.interpret_response(response_message) + async def resolve_target( + self, + target: Union[Address, str], + aliases: Optional[Dict[str, str]] = None, + ) -> Address: + """Resolve Address | alias | dot-path to Address.""" + if isinstance(target, Address): + return target + resolved = aliases.get(target, target) if aliases is not None else target + return await self.resolve_path(resolved) async def stop(self): - """Stop the handler and close connection.""" try: await self.io.stop() except Exception as e: logger.warning(f"Error during stop: {e}") finally: self._connected = False - logger.info("Hamilton handler stopped") + self._invalidate_introspection_session() + logger.info("Hamilton TCP client stopped") diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py new file mode 100644 index 00000000000..66ddc7bf0c7 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -0,0 +1,966 @@ +"""Curated tests for Hamilton TCP protocol implementation. + +Focused on high-value invariants: +- packet/frame wire shape and round-trip parsing +- DataFragment encode/decode and parser behavior +- warning/exception payload semantics +- command response auto-decode contract +""" + +from __future__ import annotations + +import asyncio +import struct +import unittest +from dataclasses import dataclass +from typing import Annotated, cast +from unittest.mock import AsyncMock + +import pylabrobot.hamilton.transport.tcp.introspection as introspection_mod +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_entry, +) +from pylabrobot.hamilton.transport.tcp.introspection import ( + EnumInfo, + FirmwareTreeNode, + GlobalTypePool, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + ObjectRegistry, + StructInfo, + TypeRegistry, + flatten_firmware_tree, +) +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, + parse_into_struct, + split_hoi_params_after_warning_prefix, +) +from pylabrobot.hamilton.transport.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, + decode_version_byte, + encode_version_byte, +) +from pylabrobot.hamilton.transport.tcp.protocol import ( + HamiltonProtocol, + Hoi2Action, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient +from pylabrobot.hamilton.transport.tcp.wire_types import ( + I32, + I64, + U16, + Bool, + BoolArray, + CountedFlatArray, + HamiltonDataType, + HcResultEntry, + Str, + StrArray, + decode_fragment, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError + + +@dataclass +class _EnumValueWire: + name: Str + value: I64 + + +@dataclass +class _EnumWire: + enum_id: I64 + name: Str + values: Annotated[list[_EnumValueWire], CountedFlatArray()] + + +@dataclass +class _GetEnumsResponse: + enums: Annotated[list[_EnumWire], CountedFlatArray()] + + +class TestVersionByte(unittest.TestCase): + def test_encode_decode_roundtrip(self): + for major in range(16): + for minor in range(16): + encoded = encode_version_byte(major, minor) + got_major, got_minor = decode_version_byte(encoded) + self.assertEqual((got_major, got_minor), (major, minor)) + + def test_encode_version_byte_invalid(self): + with self.assertRaises(ValueError): + encode_version_byte(16, 0) + with self.assertRaises(ValueError): + encode_version_byte(0, 16) + + +class TestPacketWireShape(unittest.TestCase): + def test_ip_packet_roundtrip(self): + original = IpPacket(protocol=6, payload=b"\xaa\xbb", options=b"\x10\x20") + packed = original.pack() + unpacked = IpPacket.unpack(packed) + self.assertEqual(unpacked.protocol, 6) + self.assertEqual(unpacked.options, b"\x10\x20") + self.assertEqual(unpacked.payload, b"\xaa\xbb") + + def test_harp_action_bit_and_roundtrip(self): + original = HarpPacket( + src=Address(2, 1, 65535), + dst=Address(1, 1, 257), + seq=7, + protocol=2, + action_code=3, + payload=b"\x01", + response_required=True, + ) + self.assertEqual(original.action, 0x13) + unpacked = HarpPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, 3) + self.assertTrue(unpacked.response_required) + + def test_hoi_fragment_count_reflects_fragmented_params(self): + frag1 = b"\x03\x00\x04\x00" + b"\x01\x02\x03\x04" + frag2 = b"\x04\x00\x01\x00" + b"\x05" + packet = HoiPacket(interface_id=1, action_code=3, action_id=9, params=frag1 + frag2) + packed = packet.pack() + self.assertEqual(packed[5], 2) + + def test_registration_packet_roundtrip(self): + original = RegistrationPacket( + action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST, + response_code=0, + req_address=Address(2, 5, 65535), + res_address=Address(0, 0, 0), + options=b"\x05\x02\x02\x01", + ) + unpacked = RegistrationPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, original.action_code) + self.assertEqual(unpacked.req_address, original.req_address) + self.assertEqual(unpacked.options, original.options) + + +class TestHoiParamsAndParser(unittest.TestCase): + def test_bool_array_wire_shape_keeps_padding_semantics(self): + params = HoiParams().add([True, False, True], BoolArray).build() + self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) + self.assertEqual(params[1], 0x01) # padded flag required by protocol + self.assertEqual(params[2:4], b"\x04\x00") + self.assertEqual(params[4:], b"\x01\x00\x01\x00") + + def test_string_array_wire_shape(self): + params = HoiParams().add(["a", "bc"], StrArray).build() + self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) + self.assertEqual(params[2:4], b"\x05\x00") + self.assertEqual(params[4:], b"a\x00bc\x00") + + def test_parser_roundtrip_mixed_payload(self): + payload = HoiParams().add(42, I32).add("ok", Str).add(True, Bool).build() + parser = HoiParamsParser(payload) + values = [parser.parse_next()[1], parser.parse_next()[1], parser.parse_next()[1]] + self.assertEqual(values, [42, "ok", True]) + self.assertFalse(parser.has_remaining()) + + def test_decode_fragment_structure_array(self): + p1 = b"a" + p2 = b"bc" + inner = ( + bytes([HamiltonDataType.STRUCTURE, 0]) + + struct.pack(" Address: + self.assertEqual(path, "Root.Child") + return Address(1, 1, 999) + + client.resolve_path = _fake_resolve_path # type: ignore[method-assign] + got = asyncio.run( + client.resolve_target("pipettor_service", aliases={"pipettor_service": "Root.Child"}) + ) + self.assertEqual(got, Address(1, 1, 999)) + + def test_send_query_returns_hoi_payload_tuple(self): + class Cmd(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + payload = HoiParams().add(123, I32).build() + hoi = HoiPacket( + interface_id=0, action_code=Hoi2Action.COMMAND_RESPONSE, action_id=1, params=payload + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + raw = asyncio.run(client.send_query(Cmd(Address(1, 1, 257)))) + assert raw is not None + self.assertIsInstance(raw, tuple) + self.assertEqual(raw[0], HoiParams().add(123, I32).build()) + + def test_get_firmware_tree_uses_cache_and_refresh(self): + registry = ObjectRegistry() + registry.set_root_address(Address(1, 1, 100)) + + async def _unused(*a, **k): + raise RuntimeError("unused in this test") + + intro = HamiltonIntrospection( + registry=registry, + global_object_addresses=[], + send_discovery_command=_unused, + send_query=_unused, + ) + counts = {"obj": 0, "sub": 0} + root = Address(1, 1, 100) + child = Address(1, 1, 101) + + async def fake_get_object(addr: Address) -> ObjectInfo: + counts["obj"] += 1 + if addr == root: + return ObjectInfo("Root", "", method_count=2, subobject_count=1, address=addr) + return ObjectInfo("Child", "", method_count=1, subobject_count=0, address=addr) + + async def fake_get_supported(addr: Address): + return {1, 3} if addr == root else {1} + + async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: + counts["sub"] += 1 + self.assertEqual(idx, 0) + return child + + intro.get_object = fake_get_object # type: ignore[method-assign, assignment] + intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign, assignment] + intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign, assignment] + + t1 = asyncio.run(intro.get_firmware_tree()) + t2 = asyncio.run(intro.get_firmware_tree()) + t3 = asyncio.run(intro.get_firmware_tree(refresh=True)) + + self.assertIs(t1, t2) + self.assertIsNot(t1, t3) + self.assertEqual(t1.path, "Root") + self.assertEqual(len(t1.children), 1) + self.assertIn("Root.Child", str(t1)) + self.assertGreaterEqual(counts["obj"], 4) # built twice (initial + refresh) + self.assertGreaterEqual(counts["sub"], 2) + + def test_flatten_firmware_tree_preorder(self): + a0 = Address(1, 1, 10) + a1 = Address(1, 1, 11) + a2 = Address(1, 1, 12) + o0 = ObjectInfo(name="root", version="v", method_count=1, subobject_count=2, address=a0) + o1 = ObjectInfo(name="child", version="v", method_count=1, subobject_count=0, address=a1) + o2 = ObjectInfo(name="other", version="v", method_count=1, subobject_count=0, address=a2) + c1 = FirmwareTreeNode(path="R.child", address=a1, object_info=o1, children=[]) + c2 = FirmwareTreeNode(path="R.other", address=a2, object_info=o2, children=[]) + root = FirmwareTreeNode(path="R", address=a0, object_info=o0, children=[c1, c2]) + flat = flatten_firmware_tree(root) + self.assertEqual([p for p, _, _ in flat], ["R", "R.child", "R.other"]) + + def test_get_firmware_tree_flat_delegates_to_flatten(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + a0 = Address(1, 1, 20) + o0 = ObjectInfo(name="only", version="v", method_count=0, subobject_count=0, address=a0) + root = FirmwareTreeNode(path="Only", address=a0, object_info=o0, children=[]) + + async def fake_get_firmware_tree(refresh: bool = False): + del refresh + return root + + client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] + got = asyncio.run(client.introspection.get_firmware_tree_flat()) + self.assertEqual(len(got), 1) + self.assertEqual(got[0][0], "Only") + self.assertEqual(got[0][1], a0) + self.assertIs(got[0][2], o0) + + +class TestHcResultHelperUsesIntrospection(unittest.IsolatedAsyncioTestCase): + async def test_describe_entry_routes_to_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + client.introspection.get_interface_name = AsyncMock(return_value="ITest") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock( # type: ignore[method-assign] + return_value="Simulated" + ) + + iface_name, desc = await client._describe_entry(entry) + self.assertEqual(iface_name, "ITest") + self.assertEqual(desc, "Simulated") + + async def test_format_entry_context_uses_method_lookup_from_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + addr = Address(1, 1, 257) + client.registry.register( + "Root.Channel", + ObjectInfo(name="Channel", version="", method_count=0, subobject_count=0, address=addr), + ) + method = MethodInfo(interface_id=1, call_type=0, method_id=6, name="DoThing") + client.introspection.get_method_by_id = AsyncMock(return_value=method) # type: ignore[method-assign] + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + + context = await client._format_entry_context(entry) + assert context is not None + self.assertIn("path=Root.Channel", context) + self.assertIn("DoThing(void) -> void", context) + + +class TestWarningAndExceptionSemantics(unittest.TestCase): + @staticmethod + def _format_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + @classmethod + def _build_warning_params(cls, entries: list[HcResultEntry], tail: bytes = b"") -> bytes: + summary = HoiParams().add(len(entries), U16).build() + entries_frag = HoiParams().add(";".join(cls._format_entry(e) for e in entries), Str).build() + return cast(bytes, summary + entries_frag + tail) + + def test_non_warning_action_does_not_strip(self): + payload = HoiParams().add(True, Bool).build() + rest, entries = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_RESPONSE, payload) + self.assertEqual(rest, payload) + self.assertEqual(entries, []) + + def test_warning_prefix_strip_and_parse_entries(self): + entries = [HcResultEntry(1, 1, 257, 1, 6, 0x8001)] + tail = HoiParams().add(99, I32).build() + params = self._build_warning_params(entries, tail=tail) + rest, parsed = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_WARNING, params) + self.assertEqual(rest, tail) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0].result, 0x8001) + self.assertTrue(parsed[0].is_warning) + + def test_parse_hamilton_error_entry_and_entries(self): + e1 = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + e2 = HcResultEntry(1, 1, 257, 1, 6, 0x0F09) + + one = HoiParams().add(self._format_entry(e1), Str).build() + got_one = parse_hamilton_error_entry(one) + assert got_one is not None + self.assertEqual(got_one.result, 0x0F08) + + two = HoiParams().add(self._format_entry(e1), Str).add(self._format_entry(e2), Str).build() + got_two = parse_hamilton_error_entries(two) + self.assertEqual([e.result for e in got_two], [0x0F08, 0x0F09]) + + +class TestErrorEntryChannelDetection(unittest.TestCase): + @dataclass + class _Ap: + channel: int + + @dataclass + class _CmdPrep(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + dest: Address + aspirate_parameters: list + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_struct_array_has_channel(self): + c = TestErrorEntryChannelDetection._CmdPrep( + Address(1, 1, 1), aspirate_parameters=[TestErrorEntryChannelDetection._Ap(0)] + ) + self.assertTrue(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def test_false_for_void_command(self): + c = TestErrorEntryChannelDetection._CmdVoid(Address(1, 1, 1)) + self.assertFalse(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdNimbus(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_channels_involved_present(self): + c = TestErrorEntryChannelDetection._CmdNimbus(Address(1, 1, 1), (1, 0)) + self.assertTrue(c.error_entries_use_physical_channels()) + + +class TestSendCommandStatusException(unittest.IsolatedAsyncioTestCase): + @staticmethod + def _format_wire_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + async def test_void_command_raises_hoi_error(self): + entry = HcResultEntry(1, 1, 5376, 1, 35, 0x0206) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 5376), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="MLPrep") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdVoid(Address(1, 1, 5376)) + with self.assertRaises(HoiError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.exceptions) + self.assertEqual(ctx.exception.entries[0].result, 0x0206) + + async def test_channels_involved_raises_channelized_error(self): + entry = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdPick(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdPick(Address(1, 1, 257), (1, 0)) + with self.assertRaises(ChannelizedError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.errors) + self.assertEqual(len(ctx.exception.kwargs["hoi_entries"]), 1) + self.assertIn(0, ctx.exception.kwargs["hoi_exceptions"]) + + +class TestHcResultDescriptionNimbusTable(unittest.IsolatedAsyncioTestCase): + """NIMBUS_ERROR_CODES keys use interface_id in the 4th slot; describe_entry must match that.""" + + async def test_lookup_uses_interface_id_not_method_id(self): + class _NimbusClient(HamiltonTCPClient): + _ERROR_CODES = NIMBUS_ERROR_CODES + + client = _NimbusClient(host="127.0.0.1", port=0) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + entry = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4E) + _iface, desc = await client._describe_entry(entry) + self.assertIn("Tip Detected Not Correct Tip", desc) + entry_b = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4B) + _iface_b, desc_b = await client._describe_entry(entry_b) + self.assertIn("No Tip Picked Up", desc_b) + + +class TestCountedFlatArrayDecode(unittest.TestCase): + def test_counted_flat_array_nested_decode(self): + data = ( + HoiParams() + .add(1, I64) # enum_count + .add(1, I64) # enum_id + .add("E1", Str) + .add(2, I64) # value_count + .add("v1", Str) + .add(10, I64) + .add("v2", Str) + .add(20, I64) + .build() + ) + + parsed = parse_into_struct(HoiParamsParser(data), _GetEnumsResponse) + self.assertEqual(len(parsed.enums), 1) + self.assertEqual(parsed.enums[0].name, "E1") + self.assertEqual([v.name for v in parsed.enums[0].values], ["v1", "v2"]) + self.assertEqual([v.value for v in parsed.enums[0].values], [10, 20]) + + def test_i16_array_roundtrip_decode_fragment(self): + payload = struct.pack(" tuple[int, ...]: + """Collect all IDs from rows matching a boolean flag (is_struct_kind, is_enum_kind, etc.).""" + ids: list[int] = [] + for row in introspection_mod._HOI_TYPE_ROWS: + if getattr(row, flag): + ids.extend(tid for tid in row.ids if tid != 0) + return tuple(ids) + + def test_complex_method_and_struct_sets_are_disjoint(self): + self.assertTrue( + introspection_mod._COMPLEX_METHOD_TYPE_IDS.isdisjoint( + introspection_mod._COMPLEX_STRUCT_TYPE_IDS + ) + ) + + def test_method_param_struct_and_enum_ref_types_are_disjoint(self): + struct_wire = {HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY} + enum_wire = {HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY} + self.assertTrue(struct_wire.isdisjoint(enum_wire)) + + def test_method_param_type_struct_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_struct_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_struct_field_type_struct_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + def test_method_param_type_enum_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_enum_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_enum_ref) + self.assertFalse(pt.is_struct_ref) + + def test_struct_field_type_enum_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_enum_ref) + self.assertFalse(sft.is_struct_ref) + + def test_scalar_method_param_type_is_not_a_reference(self): + row = next(r for r in introspection_mod._HOI_TYPE_ROWS if r.display_name == "i32") + pt = introspection_mod.MethodParamType(row.wire_type, introspection_mod.Direction.In) + self.assertFalse(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_scalar_struct_field_type_is_not_complex_or_reference(self): + sft = introspection_mod.StructFieldType(HamiltonDataType.F32) + self.assertFalse(sft.is_complex) + self.assertFalse(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + +class TestIntrospectionTypeParsers(unittest.TestCase): + def test_parse_method_param_types_supports_simple_ref_and_node_global(self): + # [i8 In] + [struct In source=2 id=1] + [struct In source=4 id=9 "01" ] + raw = [1, 57, 2, 1, 57, 4, 9, 0x22, 0x30, 0x31, 0x22, 0x20] + parsed = introspection_mod._parse_method_param_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual( + [pt.wire_type for pt in parsed], + [HamiltonDataType.I8, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE], + ) + self.assertEqual( + [pt.direction for pt in parsed], + [ + introspection_mod.Direction.In, + introspection_mod.Direction.In, + introspection_mod.Direction.In, + ], + ) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 8]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 1)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 9)) + + def test_parse_struct_field_types_supports_simple_ref_and_node_global(self): + # [F32 simple] + [STRUCT source=2 id=3] + [STRUCT source=4 id=7 ModHi ModLo NodeHi NodeLo] + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual( + [pt.type_id for pt in parsed], + [HamiltonDataType.F32, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE], + ) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 7]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 3)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 7)) + + def test_struct_parser_byte_width_sum_matches_cursor_advance(self): + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + bytes_used = sum(pt._byte_width for pt in parsed[:3]) + self.assertEqual(bytes_used, len(raw)) + + +class TestHamiltonIntrospectionLazyCaches(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.addr = Address(1, 1, 99) + + async def _should_not_be_called(*a, **k): + raise AssertionError("transport should be patched out in introspection cache tests") + + self.intro = HamiltonIntrospection( + registry=ObjectRegistry(), + global_object_addresses=[], + send_discovery_command=_should_not_be_called, + send_query=_should_not_be_called, + ) + + async def test_second_ensure_method_table_skips_get_method(self): + info = ObjectInfo(name="O", version="", method_count=2, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + gm = AsyncMock( + side_effect=[ + MethodInfo(1, 0, 0, "a", [], [], [], []), + MethodInfo(1, 0, 1, "b", [], [], [], []), + ] + ) + self.intro.get_method = gm # type: ignore[method-assign] + r1 = await self.intro.ensure_method_table(self.addr) + self.assertEqual(len(r1), 2) + self.assertEqual(gm.call_count, 2) + r2 = await self.intro.methods_for_interface(self.addr, 1) + self.assertEqual(len(r2), 2) + self.assertEqual(gm.call_count, 2) + r3 = await self.intro.ensure_method_table(self.addr) + self.assertIs(r1, r3) + + async def test_lazy_signature_loads_only_referenced_iface(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType( + HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1 + ) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + touched: list[int] = [] + + async def fake_ensure(addr, iface_id): + touched.append(iface_id) + key = (addr, iface_id) + self.intro._iface_types[key] = ({0: st}, {}) + + self.intro.ensure_structs_enums = fake_ensure # type: ignore[method-assign] + + sig = await self.intro.resolve_signature(self.addr, 1, 3) + self.assertIn("TipParams", sig) + self.assertEqual(touched, [1]) + + async def test_lazy_signature_matches_full_registry_for_local_struct(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType( + HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1 + ) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[st]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + + lazy_sig = await self.intro.resolve_signature(self.addr, 1, 3) + + full = TypeRegistry(address=self.addr, global_pool=GlobalTypePool()) + full.methods = [m] + full.structs[1] = {0: st} + full_sig = m.get_signature_string(full) + self.assertEqual(lazy_sig, full_sig) + + async def test_interface_name_and_hc_result_text_use_introspection_session_cache(self): + self.intro.get_interfaces = AsyncMock( # type: ignore[method-assign] + return_value=[InterfaceInfo(interface_id=1, name="ITest", version="")] + ) + name1 = await self.intro.get_interface_name(self.addr, 1) + name2 = await self.intro.get_interface_name(self.addr, 1) + self.assertEqual(name1, "ITest") + self.assertEqual(name2, "ITest") + self.assertEqual(self.intro.get_interfaces.call_count, 1) + + self.intro.get_supported_interface0_method_ids = AsyncMock(return_value={5, 6}) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock( # type: ignore[method-assign] + return_value=[ + EnumInfo( + enum_id=0, + name="HcResult", + values={"OK": 0, "SomethingFailed": 0xF08}, + ) + ] + ) + text1 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + text2 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + self.assertEqual(text1, "SomethingFailed") + self.assertEqual(text2, "SomethingFailed") + self.assertEqual(self.intro.get_enums.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/transport/tcp/wire_types.py b/pylabrobot/hamilton/transport/tcp/wire_types.py new file mode 100644 index 00000000000..d60adde3eb7 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/wire_types.py @@ -0,0 +1,393 @@ +"""Unified bidirectional codec layer for Hamilton DataFragments. + +Single source of truth for DataFragment type IDs, encoding, and decoding. Each +WireType handles both encode (encode_into) and decode (decode_from) via the +same format; no separate dispatch tables or coercion blocks. + +Layout: (1) HamiltonDataType enum (type IDs for [type_id:1][flags:1][length:2] +[data:N]); (2) WireType hierarchy and Annotated type aliases (I32, F32, Bool, +Str, I32Array, etc.); (3) type registry and decode_fragment(). HoiParams and +HoiParamsParser delegate to this layer exclusively. +""" + +from __future__ import annotations + +import struct as _struct +from dataclasses import dataclass +from enum import IntEnum +from typing import TYPE_CHECKING, Annotated, Any + +if TYPE_CHECKING: + from pylabrobot.hamilton.transport.tcp.messages import HoiParams + + +# --------------------------------------------------------------------------- +# Hamilton DataFragment type IDs (codec layer) +# --------------------------------------------------------------------------- +# Type identifiers for the DataFragment wire format [type_id:1][flags:1][length:2][data:N]. +# From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. + + +class HamiltonDataType(IntEnum): + """Hamilton parameter data types for wire encoding in DataFragments.""" + + VOID = 0 + # 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 + + # Introspection-only compound result type (no wire codec; used for HOI_RESULT method returns) + HOI_RESULT = 44 + + # 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 + + +# --------------------------------------------------------------------------- +# WireType hierarchy +# --------------------------------------------------------------------------- + + +class WireType: + """Base class: a wire-format type that can encode and decode HoiParams fragments.""" + + __slots__ = ("type_id",) + + def __init__(self, type_id: int): + self.type_id = type_id + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError + + def decode_from(self, data: bytes) -> Any: + 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) + 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).""" + + __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) + 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): + """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.hamilton.transport.tcp.messages import HoiParams as HP + + return params._add_fragment(self.type_id, HP.from_struct(value).build()) + + def decode_from(self, data: bytes) -> Any: + return data + + +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.hamilton.transport.tcp.messages import HoiParams as HP + + 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)") + + +@dataclass(frozen=True) +class HcResultEntry: + """One channel's entry in a multi-channel ``NetworkType::HoiResult``. + + Source: vendor protocol reference (6 parallel arrays + HcResultEx bit layout). + ``result`` is the raw u16 HcResult code; + the high bit (0x8000) flags a warning, bits 8-11 encode error category. + """ + + module_id: int + node_id: int + object_id: int + interface_id: int + action_id: int + result: int + + @property + def is_warning(self) -> bool: + return bool(self.result & 0x8000) + + @property + def is_success(self) -> bool: + return self.result == 0 or self.is_warning + + @property + def address(self) -> tuple[int, int, int]: + return (self.module_id, self.node_id, self.object_id) + + +class StringType(WireType): + """Null-terminated ASCII string.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + 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("utf-8") + + +class StringArrayType(WireType): + """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__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = b"" + for s in value: + data += s.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + if not data: + return [] + out: list[str] = [] + off = 0 + while off < len(data): + null_pos = data.find(b"\x00", off) + if null_pos == -1: + break + out.append(data[off:null_pos].decode("utf-8")) + off = null_pos + 1 + return out + + +# --------------------------------------------------------------------------- +# 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")] +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 = getattr(alias, "__metadata__", (None,))[0] + assert meta is not None, f"Expected Annotated alias with metadata: {alias}" + _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) From 4e71a71e0197d7c6d63f1716eee580507b32ad17 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:52:28 -0700 Subject: [PATCH 02/13] fix(hamilton.transport.tcp): run client API tests under an event loop Python 3.9 binds asyncio.Lock to the current loop at Socket construction, so HamiltonTCPClient cannot be created in sync TestCase methods. Use IsolatedAsyncioTestCase for those cases. --- .../hamilton/transport/tcp/tests/tcp_tests.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 66ddc7bf0c7..0439cd74e5e 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -9,7 +9,6 @@ from __future__ import annotations -import asyncio import struct import unittest from dataclasses import dataclass @@ -290,14 +289,16 @@ class Response: self.assertEqual(result.value, 42) -class TestTransportApiAlignment(unittest.TestCase): - def test_resolve_target_accepts_address_passthrough(self): +class TestTransportApiAlignment(unittest.IsolatedAsyncioTestCase): + """Client construction needs a running loop on Python 3.9 (``asyncio.Lock`` in ``Socket``).""" + + async def test_resolve_target_accepts_address_passthrough(self): client = HamiltonTCPClient(host="127.0.0.1", port=0) addr = Address(1, 1, 257) - got = asyncio.run(client.resolve_target(addr)) + got = await client.resolve_target(addr) self.assertEqual(got, addr) - def test_resolve_target_applies_aliases(self): + async def test_resolve_target_applies_aliases(self): client = HamiltonTCPClient(host="127.0.0.1", port=0) async def _fake_resolve_path(path: str) -> Address: @@ -305,12 +306,12 @@ async def _fake_resolve_path(path: str) -> Address: return Address(1, 1, 999) client.resolve_path = _fake_resolve_path # type: ignore[method-assign] - got = asyncio.run( - client.resolve_target("pipettor_service", aliases={"pipettor_service": "Root.Child"}) + got = await client.resolve_target( + "pipettor_service", aliases={"pipettor_service": "Root.Child"} ) self.assertEqual(got, Address(1, 1, 999)) - def test_send_query_returns_hoi_payload_tuple(self): + async def test_send_query_returns_hoi_payload_tuple(self): class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -338,12 +339,12 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] client = FakeClient(host="127.0.0.1", port=0) client.client_address = Address(2, 1, 65535) - raw = asyncio.run(client.send_query(Cmd(Address(1, 1, 257)))) + raw = await client.send_query(Cmd(Address(1, 1, 257))) assert raw is not None self.assertIsInstance(raw, tuple) self.assertEqual(raw[0], HoiParams().add(123, I32).build()) - def test_get_firmware_tree_uses_cache_and_refresh(self): + async def test_get_firmware_tree_uses_cache_and_refresh(self): registry = ObjectRegistry() registry.set_root_address(Address(1, 1, 100)) @@ -378,9 +379,9 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign, assignment] intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign, assignment] - t1 = asyncio.run(intro.get_firmware_tree()) - t2 = asyncio.run(intro.get_firmware_tree()) - t3 = asyncio.run(intro.get_firmware_tree(refresh=True)) + t1 = await intro.get_firmware_tree() + t2 = await intro.get_firmware_tree() + t3 = await intro.get_firmware_tree(refresh=True) self.assertIs(t1, t2) self.assertIsNot(t1, t3) @@ -403,7 +404,7 @@ def test_flatten_firmware_tree_preorder(self): flat = flatten_firmware_tree(root) self.assertEqual([p for p, _, _ in flat], ["R", "R.child", "R.other"]) - def test_get_firmware_tree_flat_delegates_to_flatten(self): + async def test_get_firmware_tree_flat_delegates_to_flatten(self): client = HamiltonTCPClient(host="127.0.0.1", port=0) a0 = Address(1, 1, 20) o0 = ObjectInfo(name="only", version="v", method_count=0, subobject_count=0, address=a0) @@ -414,7 +415,7 @@ async def fake_get_firmware_tree(refresh: bool = False): return root client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] - got = asyncio.run(client.introspection.get_firmware_tree_flat()) + got = await client.introspection.get_firmware_tree_flat() self.assertEqual(len(got), 1) self.assertEqual(got[0][0], "Only") self.assertEqual(got[0][1], a0) From 4ea245886dc76df2e445ac7a4abd3a6e6f5b9e76 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:33:21 -0700 Subject: [PATCH 03/13] feat(hamilton): add Prep liquid handler on TCP transport Plain Prep package with channels/head8/gripper over HamiltonTCPClient, kwargs for LH params, local op types, PrepDeck, and liquid_class_resolver. --- docs/api/pylabrobot.hamilton.rst | 7 + pylabrobot/hamilton/liquid_class_resolver.py | 100 + pylabrobot/hamilton/prep/__init__.py | 13 + pylabrobot/hamilton/prep/calibration.py | 587 ++++ pylabrobot/hamilton/prep/channels.py | 2342 +++++++++++++++ pylabrobot/hamilton/prep/chatterbox.py | 183 ++ pylabrobot/hamilton/prep/client.py | 195 ++ pylabrobot/hamilton/prep/gripper.py | 302 ++ pylabrobot/hamilton/prep/head8.py | 1046 +++++++ pylabrobot/hamilton/prep/info.py | 281 ++ pylabrobot/hamilton/prep/method.py | 55 + pylabrobot/hamilton/prep/prep.py | 269 ++ pylabrobot/hamilton/prep/prep_commands.py | 2621 +++++++++++++++++ pylabrobot/hamilton/prep/standard.py | 248 ++ pylabrobot/hamilton/prep/tests/__init__.py | 0 .../hamilton/prep/tests/channels_tests.py | 46 + .../hamilton/prep/tests/client_tests.py | 177 ++ pylabrobot/hamilton/prep/tests/head8_tests.py | 645 ++++ pylabrobot/hamilton/tests/__init__.py | 0 .../tests/liquid_class_resolver_tests.py | 109 + pylabrobot/resources/hamilton/__init__.py | 5 +- .../resources/hamilton/hamilton_decks.py | 107 +- pylabrobot/resources/hamilton/nimbus_decks.py | 60 +- pylabrobot/resources/hamilton/tip_carriers.py | 17 + 24 files changed, 9407 insertions(+), 8 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_class_resolver.py create mode 100644 pylabrobot/hamilton/prep/__init__.py create mode 100644 pylabrobot/hamilton/prep/calibration.py create mode 100644 pylabrobot/hamilton/prep/channels.py create mode 100644 pylabrobot/hamilton/prep/chatterbox.py create mode 100644 pylabrobot/hamilton/prep/client.py create mode 100644 pylabrobot/hamilton/prep/gripper.py create mode 100644 pylabrobot/hamilton/prep/head8.py create mode 100644 pylabrobot/hamilton/prep/info.py create mode 100644 pylabrobot/hamilton/prep/method.py create mode 100644 pylabrobot/hamilton/prep/prep.py create mode 100644 pylabrobot/hamilton/prep/prep_commands.py create mode 100644 pylabrobot/hamilton/prep/standard.py create mode 100644 pylabrobot/hamilton/prep/tests/__init__.py create mode 100644 pylabrobot/hamilton/prep/tests/channels_tests.py create mode 100644 pylabrobot/hamilton/prep/tests/client_tests.py create mode 100644 pylabrobot/hamilton/prep/tests/head8_tests.py create mode 100644 pylabrobot/hamilton/tests/__init__.py create mode 100644 pylabrobot/hamilton/tests/liquid_class_resolver_tests.py diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst index 45234392833..8b31c3739db 100644 --- a/docs/api/pylabrobot.hamilton.rst +++ b/docs/api/pylabrobot.hamilton.rst @@ -2,3 +2,10 @@ pylabrobot.hamilton package =========================== + +.. autosummary:: + :toctree: _autosummary + :recursive: + + prep + transport.tcp diff --git a/pylabrobot/hamilton/liquid_class_resolver.py b/pylabrobot/hamilton/liquid_class_resolver.py new file mode 100644 index 00000000000..207dccf84bf --- /dev/null +++ b/pylabrobot/hamilton/liquid_class_resolver.py @@ -0,0 +1,100 @@ +"""Resolve Hamilton liquid classes and corrected volumes for Prep PIP ops. + +Automatic lookup defaults to +:func:`~pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star.get_star_liquid_class` +(STAR calibration tables); pass ``lookup=`` for instrument-specific tables. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional, Sequence, Union + +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources.hamilton import HamiltonTip +from pylabrobot.resources.liquid import Liquid + +_Lookup = Callable[..., Optional[HamiltonLiquidClass]] + + +def resolve_hamilton_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + *, + jet: Union[bool, List[bool]] = False, + blow_out: Union[bool, List[bool]] = False, + is_aspirate: bool = True, + lookup: Optional[_Lookup] = None, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, resolve from each op's tip via ``lookup`` (default + :func:`get_star_liquid_class`). Non-``HamiltonTip`` tips yield ``None``. + + If ``explicit`` is a list, it is returned as a shallow copy; ``None`` entries + are preserved (legacy STAR behavior). + + Args: + explicit: Caller-provided liquid classes, or None for automatic lookup. + ops: Aspiration or dispense operations (must have a ``tip`` attribute). + jet: Per-op or scalar flags passed to automatic liquid class lookup. + blow_out: Per-op or scalar flags passed to automatic liquid class lookup. + is_aspirate: Reserved for API compatibility with STAR; unused. + lookup: Optional callable with the same signature as ``get_star_liquid_class``. + """ + del is_aspirate + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + if lookup is None: + # Lazy import avoids circular import: star package __init__ may pull in pip_backend, + # which imports this module. + from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class + + fn = get_star_liquid_class + else: + fn = lookup + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + fn( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + +def corrected_volumes_for_ops( + ops: Sequence[Any], + hlcs: Sequence[Optional[HamiltonLiquidClass]], + disable_volume_correction: Optional[Sequence[bool]] = None, +) -> List[float]: + """Apply liquid-class volume correction per op when enabled.""" + n = len(ops) + if len(hlcs) != n: + raise ValueError(f"hlcs length must match ops ({n}), got {len(hlcs)}") + dvc = list(disable_volume_correction) if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match ops ({n}), got {len(dvc)}") + return [ + float(hlc.compute_corrected_volume(op.volume)) + if hlc is not None and not disabled + else float(op.volume) + for op, hlc, disabled in zip(ops, hlcs, dvc) + ] diff --git a/pylabrobot/hamilton/prep/__init__.py b/pylabrobot/hamilton/prep/__init__.py new file mode 100644 index 00000000000..0e2f21016a0 --- /dev/null +++ b/pylabrobot/hamilton/prep/__init__.py @@ -0,0 +1,13 @@ +"""Hamilton Prep liquid handler.""" + +from pylabrobot.hamilton.prep.calibration import PrepCalibration +from pylabrobot.hamilton.prep.chatterbox import PrepChatterboxClient +from pylabrobot.hamilton.prep.client import PrepClient +from pylabrobot.hamilton.prep.prep import Prep + +__all__ = [ + "Prep", + "PrepCalibration", + "PrepChatterboxClient", + "PrepClient", +] diff --git a/pylabrobot/hamilton/prep/calibration.py b/pylabrobot/hamilton/prep/calibration.py new file mode 100644 index 00000000000..323c36ce37f --- /dev/null +++ b/pylabrobot/hamilton/prep/calibration.py @@ -0,0 +1,587 @@ +"""Prep calibration: MLPrepCalibration commands and session workflows. + +Firmware-path resolution is JIT: each ``PrepCommand`` subclass declares its own +``firmware_path``, and :meth:`PrepClient.send_command` resolves it via the +introspection registry (cache-hot after the first call). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) + +from pylabrobot.resources.tip_rack import TipSpot + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_TCalibResult = TypeVar("_TCalibResult") + +# Same mapping as Prep channels for TipPositionParameters / channel indices. +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class CalibrationCommandReport: + """Structured report for one calibration command execution.""" + + command: str + result: object + before: PrepCmd.CalibrationValues + after: PrepCmd.CalibrationValues + diff: PrepCmd.CalibrationValuesDiff + + @property + def changed_fields_count(self) -> int: + channel_changes = sum( + len(cd.changes) for cd in self.diff.channel_diffs if cd.state == "changed" + ) + return ( + len(self.diff.top_level_changes) + + channel_changes + + sum(1 for cd in self.diff.channel_diffs if cd.state in ("added", "removed")) + ) + + +class PrepCalibration: + """Calibration façade: firmware MLPrepCalibration object + DeckConfiguration site defs.""" + + def __init__(self, *, driver: "PrepClient", info: "PrepInstrumentInfo") -> None: + self._driver = driver + self._info = info + self._calibration_session_active: bool = False + + @property + def client(self) -> "PrepClient": + """Alias for code that uses ``client.send_command`` (driver is the TCP client).""" + return self._driver + + @property + def num_channels(self) -> int: + n = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self._info.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + def _set_calibration_session_active(self, active: bool) -> None: + self._calibration_session_active = active + + def calibration_session( + self, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> PrepCalibrationSession: + """Create a managed calibration session bound to this façade.""" + return PrepCalibrationSession( + self, + float_tol=float_tol, + report_after_command=report_after_command, + report_scope=report_scope, + session_read_timeout=session_read_timeout, + ) + + async def get_calibration_site_definitions(self) -> Tuple[PrepCmd.CalibrationSiteInfo, ...]: + """Return calibration site definitions from DeckConfiguration (GetCalibrationSiteDefinitions, cmd=3).""" + result = await self._driver.send_command(PrepCmd.PrepGetCalibrationSiteDefinitions()) + if result is None or not getattr(result, "sites", None): + return () + return tuple( + PrepCmd.CalibrationSiteInfo( + 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), + post=bool(s.post), + ) + for s in result.sites + ) + + async def begin_calibration(self) -> None: + """Enter calibration mode (BeginCalibration, cmd=1).""" + await self._driver.send_command(PrepCmd.PrepBeginCalibration()) + + async def cancel_calibration(self) -> None: + """Cancel an active calibration session (CancelCalibration, cmd=2).""" + await self._driver.send_command(PrepCmd.PrepCancelCalibration()) + + async def end_calibration(self, date_time: Optional[PrepCmd.HoiDateTime] = None) -> None: + """End calibration and store results with timestamp (EndCalibration, cmd=3).""" + if date_time is None: + date_time = PrepCmd.HoiDateTime.now() + await self._driver.send_command(PrepCmd.PrepEndCalibration(date_time=date_time)) + + async def reset_calibration(self, store: bool = False) -> None: + """Reset calibration data (ResetCalibration, cmd=4).""" + await self._driver.send_command(PrepCmd.PrepResetCalibration(store=store)) + + async def calibration_initialize(self) -> None: + """Initialize calibration hardware (CalibrationInitialize, cmd=5).""" + await self._driver.send_command(PrepCmd.PrepCalibrationInitialize()) + + async def read_calibration_values( + self, read_timeout: Optional[float] = None + ) -> PrepCmd.CalibrationValues: + """Read calibration values (GetCalibrationValues, cmd=16).""" + result = await self._driver.send_command( + PrepCmd.PrepGetCalibrationValues(), + read_timeout=read_timeout, + ) + if result is None: + return PrepCmd.CalibrationValues( + independent_offset_x=0.0, + mph_offset_x=0.0, + channel_values=(), + ) + + return PrepCmd.CalibrationValues( + independent_offset_x=float(result.independent_offset_x), + mph_offset_x=float(result.mph_offset_x), + channel_values=tuple( + PrepCmd.ChannelCalibrationValuesInfo( + index=int(cv.index), + y_offset=float(cv.y_offset), + z_offset=float(cv.z_offset), + squeeze_position=int(cv.squeeze_position), + z_touchoff=int(cv.z_touchoff), + pressure_shift=int(cv.pressure_shift), + pressure_monitoring_shift=int(cv.pressure_monitoring_shift), + dispenser_return_distance=float(cv.dispenser_return_distance), + z_tip_height=float(cv.z_tip_height), + core_ii=bool(cv.core_ii), + ) + for cv in (result.channel_values or []) + ), + ) + + +class PrepCalibrationSession: + """Context manager for stateful Prep calibration workflows.""" + + def __init__( + self, + cal: PrepCalibration, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> None: + self._cal = cal + self.float_tol = float_tol + self.report_after_command = report_after_command + self.report_scope = report_scope + self.session_read_timeout = session_read_timeout + + self._started = False + self._ended = False + self._baseline: Optional[PrepCmd.CalibrationValues] = None + self._last_snapshot: Optional[PrepCmd.CalibrationValues] = None + self.history: List[CalibrationCommandReport] = [] + + if report_scope not in ("related", "full"): + raise ValueError(f"report_scope must be 'related' or 'full', got: {report_scope}") + + @property + def baseline(self) -> PrepCmd.CalibrationValues: + if self._baseline is None: + raise RuntimeError("Session baseline unavailable. Enter the session first.") + return self._baseline + + @property + def last_snapshot(self) -> PrepCmd.CalibrationValues: + if self._last_snapshot is None: + raise RuntimeError("Session snapshot unavailable. Enter the session first.") + return self._last_snapshot + + def _effective_timeout(self, read_timeout: Optional[float]) -> Optional[float]: + return self.session_read_timeout if read_timeout is None else read_timeout + + def _ensure_started(self) -> None: + if not self._started: + raise RuntimeError("Calibration session is not started. Call `await session.start()` first.") + if self._ended: + raise RuntimeError("Calibration session is already ended.") + + def _select_snapshot_scope( + self, + values: PrepCmd.CalibrationValues, + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + ) -> PrepCmd.CalibrationValues: + if self.report_scope == "full" or channel is None: + return values + channel_index = int(channel) + return PrepCmd.CalibrationValues( + independent_offset_x=values.independent_offset_x, + mph_offset_x=values.mph_offset_x, + channel_values=tuple(cv for cv in values.channel_values if cv.index == channel_index), + ) + + def _log_report(self, report: CalibrationCommandReport) -> None: + if report.diff.has_changes: + logger.info( + "Calibration session %s changed %d field(s)", + report.command, + report.changed_fields_count, + ) + else: + logger.info("Calibration session %s produced no calibration changes", report.command) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calibration report diff for %s:\n%s", + report.command, + PrepCmd.format_calibration_diff(report.diff), + ) + + async def _get_calibration_values( + self, + *, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValues: + return await self._cal.read_calibration_values(read_timeout=read_timeout) + + async def _run_with_report( + self, + command_name: str, + op: Callable[[Optional[float]], Awaitable[_TCalibResult]], + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + read_timeout: Optional[float] = None, + ) -> Union[_TCalibResult, CalibrationCommandReport]: + timeout = self._effective_timeout(read_timeout) + if not self.report_after_command: + result = await op(timeout) + self._last_snapshot = await self._get_calibration_values(read_timeout=timeout) + return result + + before_full = await self._get_calibration_values(read_timeout=timeout) + result = await op(timeout) + after_full = await self._get_calibration_values(read_timeout=timeout) + self._last_snapshot = after_full + + before = self._select_snapshot_scope(before_full, channel=channel) + after = self._select_snapshot_scope(after_full, channel=channel) + diff = PrepCmd.diff_calibration_values(before, after, float_tol=self.float_tol) + report = CalibrationCommandReport( + command=command_name, + result=result, + before=before, + after=after, + diff=diff, + ) + self.history.append(report) + self._log_report(report) + return report + + async def __aenter__(self) -> PrepCalibrationSession: + await self.start() + return self + + async def start(self) -> PrepCalibrationSession: + """Start calibration mode and capture baseline snapshot.""" + if self._started: + return self + if self._ended: + raise RuntimeError("Calibration session is already ended; create a new session.") + if self._cal._calibration_session_active: + raise RuntimeError("A calibration session is already active on this PrepCalibration.") + await self._cal.begin_calibration() + await self._cal.calibration_initialize() + self._cal._set_calibration_session_active(True) + try: + snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + except Exception: + self._cal._set_calibration_session_active(False) + raise + self._baseline = snapshot + self._last_snapshot = snapshot + self._started = True + logger.info("Calibration session started") + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + if self._ended: + return False + try: + await self.end(save=False) + except Exception: + logger.exception("Failed to rollback calibration session") + if exc is None: + raise + return False + + async def snapshot(self, *, read_timeout: Optional[float] = None) -> PrepCmd.CalibrationValues: + self._ensure_started() + snapshot = await self._get_calibration_values( + read_timeout=self._effective_timeout(read_timeout) + ) + self._last_snapshot = snapshot + return snapshot + + async def diff_from_start( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + self.baseline, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def diff_from_last( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + previous = self.last_snapshot + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + previous, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def end( + self, *, save: bool = True, date_time: Optional[PrepCmd.HoiDateTime] = None + ) -> None: + """End the calibration session, optionally saving values.""" + if self._ended: + return + self._ensure_started() + if save: + await self._cal.end_calibration(date_time=date_time) + logger.info("Calibration session ended and saved") + else: + await self._cal.cancel_calibration() + logger.info("Calibration session ended without saving") + self._ended = True + self._started = False + self._cal._set_calibration_session_active(False) + + async def rollback(self) -> None: + """End the session without saving (alias for ``end(save=False)``).""" + await self.end(save=False) + + async def commit(self) -> None: + """Save calibration and end the session (alias for ``end(save=True)``).""" + await self.end(save=True) + + async def reset(self, *, store: bool = False) -> None: + """Reset calibration values during an active calibration session.""" + self._ensure_started() + await self._cal.reset_calibration(store=store) + self._last_snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + + async def calibrate_x_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateXAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_x_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_y_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateYAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_y_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_z_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateZAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_z_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips( + self, + tip_spots: List[TipSpot], + *, + use_channels: Optional[List[int]] = None, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + assert len(tip_spots) == len(channels) + + indexed_spots = {ch: spot for ch, spot in zip(channels, tip_spots)} + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self._cal.num_channels): + if ch not in indexed_spots: + continue + spot = indexed_spots[ch] + loc = spot.get_absolute_location("c", "c", "t") + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + ) + + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateSqueezeTips( + channels=tip_positions, + ), + read_timeout=timeout, + ) + if result is None or not getattr(result, "positions", None): + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips", + _op, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + *, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + if not self._cal.has_mph: + raise RuntimeError( + "Instrument does not have an 8MPH head. Cannot use calibrate_squeeze_tips_mph." + ) + spots = tip_spot if isinstance(tip_spot, list) else [tip_spot] + if not spots: + raise ValueError("calibrate_squeeze_tips_mph: tip_spot list is empty") + + ref_spot = spots[0] + loc = ref_spot.get_absolute_location("c", "c", "t") + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + ref_spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateSqueezeTips( + channels=[tip_position], + ), + read_timeout=timeout, + ) + if result is None or not getattr(result, "positions", None): + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips_mph", + _op, + channel=PrepCmd.ChannelIndex.MPHChannel, + read_timeout=read_timeout, + ) + + +__all__ = [ + "CalibrationCommandReport", + "PrepCalibration", + "PrepCalibrationSession", +] diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py new file mode 100644 index 00000000000..1293da30dc2 --- /dev/null +++ b/pylabrobot/hamilton/prep/channels.py @@ -0,0 +1,2342 @@ +"""PrepChannels: dual-channel pipettor ops plus per-channel discovery. + +Channel-scoped topology discovery, bounds parsing, and per-channel firmware +queries live alongside tip pickup/drop and aspirate/dispense orchestration. + +The firmware object tree exposes channel internals as a single template under +``MLPrepRoot.Channel Root.Channel`` (and an analogous ``MLPrepRoot.MPH Channel +Root.Channel`` for MPH). Individual physical channels share that template — +per-channel identity lives in the node-ID component of the Address. We probe +the full object tree and match children by **path prefix** +(``".Channel Root.Channel.Squeeze.SDrive"``) rather than computing node +IDs directly. +""" + +from __future__ import annotations + +import enum +import logging +import math +import struct as _struct +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Generic, + List, + Literal, + NamedTuple, + Optional, + Sequence, + Tuple, + TypedDict, + TypeVar, + Union, +) + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.hamilton.prep.standard import ( + Aspiration, + Dispense, + Pickup, + TipDrop, +) +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Coordinate, Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well + +from . import prep_commands as PrepCmd +from .client import PIPETTOR_OBJECT_PATH + +if TYPE_CHECKING: + from pylabrobot.resources.deck import Deck + + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T") +_OpT = TypeVar("_OpT", Aspiration, Dispense) + + +# ============================================================================= +# Shared pure helpers (also imported by PrepHead8) +# ============================================================================= + + +def fill_in_defaults(val: Optional[List[_T]], default: List[_T]) -> List[_T]: + """Convert optional per-channel overrides into a full list matching ``default`` length.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +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). + """ + + OFF = 0 + CAPACITIVE = 1 # STARBackend.LLDMode.GAMMA — capacitive (cLLD) + PRESSURE = 2 # pressure-based (pLLD) + DUAL = 3 # both capacitive and pressure + + +@dataclass(frozen=True) +class _LldDefaults: + """Resolved pLLD / cLLD parameter pair (shared between aspirate and dispense).""" + + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + + +def default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, +) -> _LldDefaults: + """Build resolved pLLD / cLLD defaults. + + When LLD is active and no caller override is given, returns non-default + parameters (``default_values=False``) so the firmware actually triggers + detection. Otherwise returns firmware defaults. + """ + if effective_lld: + resolved_p = p_lld or PrepCmd.PLldParameters( + default_values=False, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + resolved_c = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + resolved_p = p_lld or PrepCmd.PLldParameters.default() + resolved_c = c_lld or PrepCmd.CLldParameters.default() + return _LldDefaults(p_lld=resolved_p, c_lld=resolved_c) + + +def lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float +) -> PrepCmd.LldParameters: + """Per-channel LLD seek parameters from caller override or well geometry.""" + if effective_lld and lld is None: + return PrepCmd.LldParameters( + default_values=False, + search_start_position=top_of_well_z, + channel_speed=5.0, + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + return lld or PrepCmd.LldParameters.default() + + +def segments_to_cone_geometry( + segments: list[PrepCmd.SegmentDescriptor], fallback_radius: float +) -> Tuple[float, float, float]: + """Convert v2 frustum segments to v1 cone model (tube_radius, cone_height, cone_bottom_radius).""" + if not segments: + return (fallback_radius, 0.0, 0.0) + total_height = sum(s.height for s in segments) + if total_height <= 0: + return (fallback_radius, 0.0, 0.0) + weighted_area = sum(s.height * (s.area_top + s.area_bottom) / 2.0 for s in segments) + avg_area = weighted_area / total_height + tube_radius = math.sqrt(avg_area / math.pi) + bot = segments[0] + if abs(bot.area_bottom - bot.area_top) > 1e-6: + cone_height = bot.height + cone_bottom_radius = math.sqrt(bot.area_bottom / math.pi) + else: + cone_height = 0.0 + cone_bottom_radius = 0.0 + return (tube_radius, cone_height, cone_bottom_radius) + + +def patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] +) -> PrepCmd.CommonParameters: + """Return CommonParameters with cone geometry derived from segments (v2→v1 downgrade).""" + if len(segments) > 1: + logger.warning( + "v1 command selected: collapsing %d container segments into single cone approximation. " + "Liquid following accuracy may be reduced for complex container geometries.", + len(segments), + ) + tube_r, cone_h, cone_br = segments_to_cone_geometry(segments, common.tube_radius) + return PrepCmd.CommonParameters( + default_values=common.default_values, + empty=common.empty, + z_minimum=common.z_minimum, + z_final=common.z_final, + z_liquid_exit_speed=common.z_liquid_exit_speed, + liquid_volume=common.liquid_volume, + liquid_speed=common.liquid_speed, + transport_air_volume=common.transport_air_volume, + tube_radius=tube_r, + cone_height=cone_h, + cone_bottom_radius=cone_br, + settling_time=common.settling_time, + additional_probes=common.additional_probes, + ) + + +def resolve_command_version( + supports_v2: Optional[bool], + use_v1_flag: bool, + override: Optional[Literal["v1", "v2"]], + *, + v2_error_hint: str = "v2 commands are not supported by this firmware.", +) -> bool: + """Resolve whether to use v2 commands for a pipetting call. Returns True for v2. + + Resolution order: + 1. Per-call ``override`` ("v1" / "v2") — takes precedence. + 2. Backend-level ``use_v1_flag`` / ``supports_v2`` probe result from setup. + """ + if override == "v1": + return False + if override == "v2": + if supports_v2 is False: + raise ValueError(v2_error_hint) + return True + return supports_v2 is True + + +def lld_seek_timeout( + lld_params: PrepCmd.LldParameters, + z_minimum: float, +) -> Optional[float]: + """Compute a read timeout (s) for an LLD seek move, or None if not applicable.""" + if lld_params.channel_speed > 0: + speed: float = float(lld_params.channel_speed) + seek_distance: float = float(lld_params.search_start_position) - z_minimum + if seek_distance > 0: + return seek_distance / speed + 5.0 + return None + + +def _effective_radius(resource) -> float: + """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 + firmware's conical liquid-following model. + """ + if isinstance(resource, Well) and resource.cross_section_type == CrossSectionType.RECTANGLE: + 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: object) -> 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 PrepCmd.CommonParameters. + """ + if not isinstance(resource, Well): + return [] + well: Well = resource + + size_z = well.get_size_z() + + if well.cross_section_type == CrossSectionType.CIRCLE: + area = math.pi * (well.get_size_x() / 2) ** 2 + elif well.cross_section_type == CrossSectionType.RECTANGLE: + area = well.get_size_x() * well.get_size_y() + else: + return [] + + if well.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 = well.compute_volume_from_height(h_hi) - well.compute_volume_from_height(h_lo) + return float(dv / (h_hi - h_lo)) + + return [ + PrepCmd.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 [ + PrepCmd.SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z)) + ] + + +class _WellGeometry(NamedTuple): + """Absolute Z positions derived from well geometry.""" + + well_bottom: float + liquid_surface: float + top_of_well: float + z_air: float + + +def _absolute_z_from_well( + resource, + liquid_height: Optional[float] = None, + offset_z: float = 0.0, + z_air_margin_mm: float = 2.0, +) -> _WellGeometry: + """Compute absolute Z values from well/container geometry for aspirate/dispense. + + Args: + resource: Well or Container with get_size_z(). + liquid_height: Distance from well bottom to liquid surface (mm). None = 0. + offset_z: Additional Z applied to the bottom position (e.g. from op.offset.z). + z_air_margin_mm: Clearance above well opening for z_air (approach/exit height). + + Returns: + _WellGeometry with well_bottom, liquid_surface, top_of_well, z_air. + """ + if not hasattr(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." + ) + loc = resource.get_absolute_location("c", "c", "cavity_bottom") + well_bottom_z = loc.z + offset_z + liquid_surface_z = well_bottom_z + (liquid_height or 0.0) + top_of_well_z = loc.z + resource.get_size_z() + z_air_z = top_of_well_z + z_air_margin_mm + return _WellGeometry(well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) + + +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class ChannelDriveMap: + """Cached channel-drive topology discovered from the firmware tree. + + One entry per discovered channel for the sleeve sensor (``Squeeze.SDrive``), + the Z drive (``ZAxis.ZDrive``), and the per-node ``NodeInformation`` object + (used for firmware-string queries). Lists are parallel and sorted by tree + traversal order (same order the firmware returns Channel Root instances). + """ + + sleeve_sensor_addrs: List[Address] + zdrive_addrs: List[Address] + node_info_addrs: List[Address] + + @property + def num_channels_discovered(self) -> int: + return len(self.sleeve_sensor_addrs) + + def to_dict(self) -> dict: + """Serialize for logs / notebooks that prefer plain dicts.""" + return { + "num_channels_discovered": self.num_channels_discovered, + "sleeve_sensor_addrs": list(self.sleeve_sensor_addrs), + "zdrive_addrs": list(self.zdrive_addrs), + "node_info_addrs": list(self.node_info_addrs), + } + + +# --------------------------------------------------------------------------- +# Firmware-tree discovery — module-level so it can be called independently of +# any PrepChannels instance (used when building channels in Prep.setup, plus by +# diagnostic notebooks that hold only a client). +# --------------------------------------------------------------------------- + + +async def _find_children_by_name( + intro, + parent_addr: Address, + *names: str, +) -> dict: + """Enumerate ``parent_addr``'s subobjects; return ``{name: Address}`` for matches. + + Bounded by ``subobject_count`` on the parent. Returns early once every + requested name has been found. Children that raise on ``get_object`` (e.g. + unknown firmware types) are skipped with a debug log. + """ + parent = await intro.get_object(parent_addr) + wanted = set(names) + found: dict = {} + for i in range(parent.subobject_count): + try: + sub_addr = await intro.get_subobject_address(parent_addr, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("subobject[%d] of %s failed: %s", i, parent_addr, e) + continue + if sub.name in wanted: + found[sub.name] = sub_addr + if len(found) == len(wanted): + break + return found + + +async def discover_channel_drives( + client: "PrepClient", + *, + root_name: str = "Channel Root", +) -> ChannelDriveMap: + """Discover per-channel drive addresses via bounded subobject enumeration. + + MLPrepRoot exposes one ```` child per physical channel (siblings + with identical names, distinguished by the ``node`` component of their + :class:`Address`). For each one we walk: + + - ``.Channel.Squeeze.SDrive`` → sleeve sensor + - ``.Channel.ZAxis.ZDrive`` → Z drive + - ``.NodeInformation`` → per-channel firmware strings + + Uses ``get_subobject_address`` / ``get_object`` along the known path shape — + no full-tree traversal. Pass ``root_name="MPH Channel Root"`` for the 8MPH + head. For a full firmware-tree dump use + :meth:`PrepInstrumentInfo.get_firmware_tree`. + """ + intro = client.introspection + try: + mlprep_root = await client.resolve_path("MLPrepRoot") + root_info = await intro.get_object(mlprep_root) + except (KeyError, RuntimeError) as e: + logger.debug("MLPrepRoot unavailable (%s); skipping channel discovery", e) + return ChannelDriveMap(sleeve_sensor_addrs=[], zdrive_addrs=[], node_info_addrs=[]) + + channel_root_addrs: List[Address] = [] + for i in range(root_info.subobject_count): + try: + sub_addr = await intro.get_subobject_address(mlprep_root, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("MLPrepRoot subobject[%d] failed: %s", i, e) + continue + if sub.name == root_name: + channel_root_addrs.append(sub_addr) + + sleeve: List[Address] = [] + zdrive: List[Address] = [] + node_info: List[Address] = [] + + for ch_root in channel_root_addrs: + top = await _find_children_by_name(intro, ch_root, "Channel", "NodeInformation") + if "NodeInformation" in top: + node_info.append(top["NodeInformation"]) + + channel_addr = top.get("Channel") + if channel_addr is None: + logger.warning("%s @ %s has no 'Channel' child", root_name, ch_root) + continue + + axes = await _find_children_by_name(intro, channel_addr, "Squeeze", "ZAxis") + if (sq_parent := axes.get("Squeeze")) is not None: + sq = await _find_children_by_name(intro, sq_parent, "SDrive") + if "SDrive" in sq: + sleeve.append(sq["SDrive"]) + if (zx_parent := axes.get("ZAxis")) is not None: + zx = await _find_children_by_name(intro, zx_parent, "ZDrive") + if "ZDrive" in zx: + zdrive.append(zx["ZDrive"]) + + logger.info("Discovered %d %s channel drive pair(s)", len(channel_root_addrs), root_name) + return ChannelDriveMap( + sleeve_sensor_addrs=sleeve, + zdrive_addrs=zdrive, + node_info_addrs=node_info, + ) + + +# --------------------------------------------------------------------------- +# Per-channel movement bounds — parses PipettorService.GetChannelBounds. +# --------------------------------------------------------------------------- + + +class PrepChannelBounds(TypedDict): + """Firmware-reported movement limits for one pipettor channel (mm).""" + + x_min: float + x_max: float + y_min: float + y_max: float + z_min: float + z_max: float + + +async def request_channel_bounds(client: "PrepClient") -> List[PrepChannelBounds]: + """Request per-channel movement bounds from the firmware (cmd=10). + + Returns one dict per channel (keys ``x_min``, ``x_max``, ``y_min``, ``y_max``, + ``z_min``, ``z_max`` in mm), ordered by channel index. Returns ``[]`` when + the service cannot be resolved or the response is empty. + + 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. + """ + try: + raw = await client.send_query(PrepCmd.PrepGetChannelBounds()) + except RuntimeError: + return [] + 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: list[tuple[int, PrepChannelBounds]] = [] + + 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(" None: + self.index = index + self._client = client + self.sleeve_sensor = sleeve_sensor + self.zdrive = zdrive + self.node_info = node_info + self.bounds = bounds # x_min..z_max from firmware, or None if unavailable + + def __repr__(self) -> str: + return ( + f"PrepPIPChannel(index={self.index}, node_info={self.node_info!r}, " + f"bounds={'set' if self.bounds else 'unset'})" + ) + + async def request_firmware_version(self) -> Optional[str]: + """Per-channel firmware version string (NodeInformation cmd=8). + + Serial number is intentionally not exposed here — NodeInformation's + GetSerialNumber endpoint is unpopulated on shipped instruments, and the + canonical instrument serial (pipettor module) is already surfaced via + :meth:`PrepInstrumentInfo.get_device_serial_number`. + """ + if self.node_info is None: + return None + return await self._client._query_firmware_string(self.node_info, cmd_id=8, iface_id=1) + + +# --------------------------------------------------------------------------- +# Builder called from Prep.setup. +# --------------------------------------------------------------------------- + + +async def build_prep_channels( + client: "PrepClient", + info: "PrepInstrumentInfo", + *, + root_name: str = "Channel Root", + num_channels: Optional[int] = None, +) -> List[PrepPIPChannel]: + """Build per-channel facades, resolve drive addresses, fetch bounds. + + If ``num_channels`` is omitted, uses ``info.config.num_channels``. + """ + drive_map = await discover_channel_drives(client, root_name=root_name) + + if num_channels is None: + try: + num_channels = info.config.num_channels + except RuntimeError: + num_channels = None + if num_channels is None: + num_channels = drive_map.num_channels_discovered + + try: + bounds_list = await request_channel_bounds(client) + except Exception as e: + logger.warning("Failed to query channel bounds: %s", e) + bounds_list = [] + + def _drive_addr(attr: str, i: int) -> Optional[Address]: + if drive_map is None: + return None + seq = getattr(drive_map, attr) + return seq[i] if i < len(seq) else None + + channels: List[PrepPIPChannel] = [] + for i in range(num_channels): + channels.append( + PrepPIPChannel( + index=i, + client=client, + sleeve_sensor=_drive_addr("sleeve_sensor_addrs", i), + zdrive=_drive_addr("zdrive_addrs", i), + node_info=_drive_addr("node_info_addrs", i), + bounds=bounds_list[i] if i < len(bounds_list) else None, + ) + ) + return channels + + +# ============================================================================= +# PrepChannels — channel indices and deck routing +# ============================================================================= + + +def _build_pipettor_gantry_move_parameters( + x: float, + channels: List[int], + y: Union[float, List[float]], + z: Union[float, List[float]], +) -> PrepCmd.GantryMoveXYZParameters: + """Build :class:`~prep_commands.GantryMoveXYZParameters` for PipettorRoot move commands. + + Only ``FrontChannel`` and ``RearChannel`` may appear in ``axis_parameters``. MPH + gantry moves must use :class:`~prep_commands.MphMoveToPosition` instead. + """ + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] + for i, ch in enumerate(channels): + y_i = y[i] if isinstance(y, list) else y + z_i = z[i] if isinstance(z, list) else z + enum_ch = _CHANNEL_INDEX[ch] + if enum_ch not in ( + PrepCmd.ChannelIndex.FrontChannel, + PrepCmd.ChannelIndex.RearChannel, + ): + raise ValueError( + f"Pipettor gantry move does not support channel index {ch} (enum {enum_ch!r}). " + "MPH motion uses PrepHead8 / MphMoveToPosition on MLPrepRoot.MphRoot.MPH." + ) + axis_parameters.append( + PrepCmd.ChannelYZMoveParameters( + default_values=False, channel=enum_ch, y_position=y_i, z_position=z_i + ) + ) + return PrepCmd.GantryMoveXYZParameters( + default_values=False, gantry_x_position=x, axis_parameters=axis_parameters + ) + + +# 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" + + +@dataclass(frozen=True) +class _AspirateChannelKit: + """Pre-resolved per-channel values for one aspirate channel. + + Computed once by ``_resolve_aspirate_channels``; the variant (LLD x monitoring + x v1/v2) only decides which fields get assembled into which wire dataclass. + """ + + channel: int + aspirate: PrepCmd.AspirateParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + monitoring: PrepCmd.AspirateMonitoringParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _DispenseChannelKit: + """Pre-resolved per-channel values for one dispense channel.""" + + channel: int + dispense: PrepCmd.DispenseParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + c_lld: PrepCmd.CLldParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _ChannelContext(Generic[_OpT]): + """Shared resolved state for aspirate/dispense channel resolution. + + Computed once by ``_resolve_channel_context``; operation-specific resolve + methods add their own parameters on top. + """ + + n: int + hlcs: List[Optional[HamiltonLiquidClass]] + disable_volume_correction: List[bool] + ch_to_idx: dict[int, int] + indexed_ops: dict[int, _OpT] + volumes: List[float] + well_geometry: List[_WellGeometry] + z_minimum: List[float] + z_fluid: List[float] + z_air: List[float] + z_final: List[float] + z_bottom_search_offset: List[float] + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] + + +class PrepChannels: + """Dual-channel pipettor for Hamilton Prep. + + Narrow constructor: ``client`` (transport + JIT firmware-path resolve) and + ``info`` (instrument-wide metadata). ``self.channels`` is attached by + :meth:`Prep.setup` before :meth:`_on_setup`. + """ + + # V2 aspirate/dispense command IDs (interface 1 on Pipettor). + _V2_PIPETTING_CMD_IDS = {38, 39, 40, 41, 42, 43} + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + deck: Optional["Deck"] = None, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self.deck = deck + self._user_traverse_height: Optional[float] = default_traverse_height + self._channel_bounds: list[PrepChannelBounds] = [] + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self._supports_v2_pipetting: Optional[bool] = None + self.setup_finished: bool = False + self.channels: List[PrepPIPChannel] = [] + + 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 + + async def _probe_v2_support(self) -> bool: + """Probe the pipettor for v2 aspirate/dispense command support. + + Enumerates interface 1 method IDs on the pipettor object and checks whether + all v2 command IDs (38-43) are present. Returns False when the firmware only + exposes v1 commands (1-6). + """ + dest = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return self._V2_PIPETTING_CMD_IDS.issubset(iface1_ids) + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return resolve_command_version( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepChannels." + ), + ) + + # --------------------------------------------------------------------------- + # Setup + # --------------------------------------------------------------------------- + + async def _on_setup(self): + """Read config and probe pipettor capabilities. + + Called after ``self.channels`` is populated by :meth:`Prep.setup`. Instrument- + level initialization (``MLPrep.Initialize``) runs earlier in + :meth:`Prep.setup` — the pipettor sees an already-initialized instrument. + """ + cfg = self._info.config + logger.info( + "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " + "deck_bounds=%s, deck_sites=%d, waste_sites=%d, num_channels=%s, has_mph=%s", + cfg.has_enclosure, + cfg.safe_speeds_enabled, + cfg.default_traverse_height, + cfg.deck_bounds, + len(cfg.deck_sites), + len(cfg.waste_sites), + cfg.num_channels, + cfg.has_mph, + ) + + # Per-channel bounds are attached to ``self.channels`` by build_prep_channels. + # Keep a flat list too for legacy call sites that iterate _channel_bounds. + self._channel_bounds = [c.bounds for c in self.channels if c.bounds is not None] + 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") + + # Probe pipettor for v2 aspirate/dispense support (cmd 38-43). + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("PIP V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Pass use_v1_aspirate_dispense=True to PrepChannels to use v1 commands (cmd 1-6) instead." + ) + self._supports_v2_pipetting = True + logger.info("V2 aspirate/dispense support: True") + + self.setup_finished = True + + async def _on_stop(self): + pass + + async def discover_channel_drives(self) -> ChannelDriveMap: + """Re-walk the firmware tree and return a fresh :class:`ChannelDriveMap`. + + Diagnostic helper — channel drive addresses for normal operation are already + cached on each :attr:`channels` entry at build time. + """ + return await discover_channel_drives(self._client, root_name="Channel Root") + + # --------------------------------------------------------------------------- + # Properties + # --------------------------------------------------------------------------- + + @property + def num_channels(self) -> int: + """Number of independent dual-channel pipettor channels (1 or 2). Read from info.config.""" + n: Optional[int] = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Read from info.config.""" + try: + return bool(self._info.config.has_mph) + except RuntimeError: + return 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: + return 0 + try: + cfg = self._info.config + except RuntimeError: + return 0 + if cfg.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 _resolve_traverse_height(self, final_z: Optional[float] = None) -> 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 + try: + cfg = self._info.config + except RuntimeError: + height: Optional[float] = None + else: + height = cfg.default_traverse_height + if height is not None: + return 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 " + "PrepChannels(..., default_traverse_height=) or set_default_traverse_height(). " + "If the instrument supports it, the value is also probed during setup(); ensure setup() completed successfully." + ) from None + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense API + # --------------------------------------------------------------------------- + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + *, + 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, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ): + """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). + """ + assert len(ops) == len(use_channels) + 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[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 = 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 = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + locs = [indexed_ops[ch].resource.get_absolute_location("c", "c", "t") for ch in use_channels] + await self.move_to_position( + x=locs[0].x, + y=[loc.y for loc in locs], + z=traverse_h, + use_channels=use_channels, + ) + + await self._client.send_command( + PrepCmd.PrepPickUpTips( + tip_positions=tip_positions, + 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, + ) + ) + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + *, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + drop_type: PrepCmd.TipDropType = PrepCmd.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 - 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. + """ + assert len(ops) == len(use_channels) + if use_channels: + assert max(use_channels) < self.num_channels, ( + 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 = PrepCmd.TipDropType.Stall if all_trash else drop_type + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + tip_positions: List[PrepCmd.TipDropParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + tip = op.tip + if all_trash: + if self.deck is None: + raise ValueError( + "Cannot drop tips to waste: backend has no deck (assign a deck before drop_tips)." + ) + 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: + loc = op.resource.get_absolute_location("c", "c", "t") + op.offset + 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( + PrepCmd.PrepDropTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + # --------------------------------------------------------------------------- + # V1/V2 aspirate/dispense dispatch helpers + # --------------------------------------------------------------------------- + + @staticmethod + def _patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] + ) -> PrepCmd.CommonParameters: + return patch_common_with_cone(common, segments) + + # --------------------------------------------------------------------------- + # Shared LLD / TADM resolution helpers + # --------------------------------------------------------------------------- + + def _resolve_effective_lld( + self, + lld_mode: Optional[List[LLDMode]], + lld: Optional[PrepCmd.LldParameters], + n: int, + *, + allowed_modes: Optional[frozenset[LLDMode]] = None, + ) -> bool: + """Determine whether LLD is active for this pipetting call. + + Validates ``lld_mode`` length, rejects disallowed modes (e.g. PRESSURE for + dispense), enforces all-or-nothing across channels, and returns a single bool. + Falls back to ``lld`` presence when ``lld_mode`` is None. + """ + 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}") + if allowed_modes is not None: + for m in lld_mode: + if m != LLDMode.OFF and m not in allowed_modes: + raise ValueError( + f"Dispense does not support {m.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + lld_on = [m != 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." + ) + return all(lld_on) + return lld is not None + + @staticmethod + def _default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + ) -> _LldDefaults: + return default_lld_params(effective_lld, p_lld, c_lld) + + @staticmethod + def _lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float + ) -> PrepCmd.LldParameters: + return lld_for_well(effective_lld, lld, top_of_well_z) + + # --------------------------------------------------------------------------- + # Shared channel resolution + # --------------------------------------------------------------------------- + + def _resolve_channel_context( + self, + ops: Sequence[_OpT], + use_channels: List[int], + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> _ChannelContext[_OpT]: + """Resolve shared per-channel state for aspirate or dispense. + + Validates inputs, resolves HLCs, computes volume corrections, well geometry, + z-parameter defaults, and container segments. Operation-specific defaults + (settling_time, flow_rate, etc.) are left to the caller. + """ + if len(ops) != len(use_channels): + raise ValueError(f"len(ops) must equal len(use_channels): {len(ops)} != {len(use_channels)}") + if use_channels and max(use_channels) >= self.num_channels: + raise ValueError(f"use_channels index out of range (valid: 0..{self.num_channels - 1})") + + n = len(ops) + if hamilton_liquid_classes is not None and len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = resolve_hamilton_liquid_classes( + list(hamilton_liquid_classes) if hamilton_liquid_classes is not None else None, + list(ops), + jet=False, + blow_out=False, + ) + dvc = disable_volume_correction if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match len(ops): {len(dvc)} != {n}") + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + + volumes = corrected_volumes_for_ops(ops, hlcs, dvc) + + well_geometry = [ + _absolute_z_from_well(op.resource, op.liquid_height, op.offset.z) for op in ops + ] + raw_traverse = self._resolve_traverse_height(None) + z_minimum = fill_in_defaults(z_minimum, [g.well_bottom for g in well_geometry]) + z_fluid = fill_in_defaults(z_fluid, [g.liquid_surface for g in well_geometry]) + z_air = fill_in_defaults(z_air, [g.z_air for g in well_geometry]) + z_final = fill_in_defaults( + z_final, [raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops] + ) + z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, [2.0] * n) + + 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] + elif auto_container_geometry: + ch_segments[ch] = _build_container_segments(indexed_ops[ch].resource) + else: + ch_segments[ch] = [] + + return _ChannelContext( + n=n, + hlcs=hlcs, + disable_volume_correction=dvc, + ch_to_idx=ch_to_idx, + indexed_ops=indexed_ops, + volumes=volumes, + well_geometry=well_geometry, + z_minimum=z_minimum, + z_fluid=z_fluid, + z_air=z_air, + z_final=z_final, + z_bottom_search_offset=z_bottom_search_offset, + ch_segments=ch_segments, + ) + + # --------------------------------------------------------------------------- + # Aspirate: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_aspirate_channels( + self, + ops: List[Aspiration], + use_channels: List[int], + effective_lld: bool, + *, + 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, + 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, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_AspirateChannelKit]: + """Resolve all per-channel values for aspirate (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Aspirate-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + prewet_volume = fill_in_defaults( + prewet_volume, + [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + 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) + ] + + lld_defaults = self._default_lld_params(effective_lld, p_lld, c_lld) + _tadm = tadm or PrepCmd.TadmParameters.default() + + kits: list[_AspirateChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + asp = ctx.indexed_ops[ch] + loc = asp.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(asp.resource) + + kits.append( + _AspirateChannelKit( + channel=_CHANNEL_INDEX[ch], + aspirate=PrepCmd.AspirateParameters.for_op( + loc, asp, prewet_volume=prewet_volume[idx], blowout_volume=blowout_volumes[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + monitoring=PrepCmd.AspirateMonitoringParameters.default(), + tadm=_tadm, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_aspirate_v2( + kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + """Assemble a v2 aspirate parameter struct from pre-resolved kit values.""" + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + def _assemble_aspirate_v1( + self, kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + """Assemble a v1 aspirate parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.PrepAspirateWithLldTadmV2, + (True, True, False): PrepCmd.PrepAspirateWithLldTadm, + (True, False, True): PrepCmd.PrepAspirateWithLldV2, + (True, False, False): PrepCmd.PrepAspirateWithLld, + (False, True, True): PrepCmd.PrepAspirateTadmV2, + (False, True, False): PrepCmd.PrepAspirateTadm, + (False, False, True): PrepCmd.PrepAspirateNoLldMonitoringV2, + (False, False, False): PrepCmd.PrepAspirateNoLldMonitoring, + } + + async def _send_aspirate( + self, + kits: list[_AspirateChannelKit], + effective_lld: bool, + is_tadm: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the aspirate command.""" + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + assembler = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + params = [assembler(k, effective_lld, is_tadm) for k in kits] + await self._client.send_command( + cmd_cls(aspirate_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Dispense: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_dispense_channels( + self, + ops: List[Dispense], + use_channels: List[int], + effective_lld: bool, + *, + 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, + 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, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_DispenseChannelKit]: + """Resolve all per-channel values for dispense (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Dispense-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + stop_back_volume = fill_in_defaults( + stop_back_volume, [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs] + ) + cutoff_speed = fill_in_defaults( + cutoff_speed, [hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs] + ) + 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) + ] + + lld_defaults = self._default_lld_params(effective_lld, c_lld=c_lld) + + kits: list[_DispenseChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + op = ctx.indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(op.resource) + + kits.append( + _DispenseChannelKit( + channel=_CHANNEL_INDEX[ch], + dispense=PrepCmd.DispenseParameters.for_op( + loc, stop_back_volume=stop_back_volume[idx], cutoff_speed=cutoff_speed[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + c_lld=lld_defaults.c_lld, + tadm=PrepCmd.TadmParameters.default(), + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_dispense_v2( + kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + """Assemble a v2 dispense parameter struct from pre-resolved kit values.""" + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + def _assemble_dispense_v1( + self, kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + """Assemble a v1 dispense parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + # Command dispatch table: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.PrepDispenseWithLldV2, + (True, False): PrepCmd.PrepDispenseWithLld, + (False, True): PrepCmd.PrepDispenseNoLldV2, + (False, False): PrepCmd.PrepDispenseNoLld, + } + + async def _send_dispense( + self, + kits: list[_DispenseChannelKit], + effective_lld: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the dispense command.""" + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + assembler = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + params = [assembler(k, effective_lld) for k in kits] + await self._client.send_command( + cmd_cls(dispense_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Public aspirate / dispense orchestrators + # --------------------------------------------------------------------------- + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + *, + 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, + lld_mode: Optional[List[Any]] = None, + 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, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Aspirate, dispatching to the appropriate command variant and version. + + Selects the command variant based on ``lld_mode`` (LLD on/off) and + ``tadm`` presence (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 + 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): 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: + ops: :class:`~pylabrobot.hamilton.prep.standard.Aspiration` ops + (``mix`` uses :class:`~pylabrobot.hamilton.prep.standard.Mix`). + + Example:: + + await channels.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) + await channels.aspirate(ops, [0], lld_mode=[LLDMode.CAPACITIVE]) + await channels.aspirate(ops, [0], tadm=PrepCmd.TadmParameters.default()) + await channels.aspirate(ops, [0], command_version="v1") + """ + effective_lld = self._resolve_effective_lld(lld_mode, lld, len(ops)) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_aspirate_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + prewet_volume=prewet_volume, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + p_lld=p_lld, + c_lld=c_lld, + tadm=tadm, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + *, + 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, + 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, + lld_mode: Optional[List[Any]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Dispense, dispatching to the appropriate command variant and version. + + The Prep firmware has 2 dispense commands (NoLld, Lld) — unlike aspirate which + splits into 4 (NoLld+Monitoring, NoLld+Tadm, Lld+Monitoring, Lld+Tadm). Both + dispense structs always carry a TADM field (sent with ``default_values=True`` + when not explicitly configured), so TADM is always available on dispense + regardless of whether ``tadm=`` is passed. + + Args: + ops: :class:`~pylabrobot.hamilton.prep.standard.Dispense` ops. + + Example:: + + await channels.dispense(ops, [0], z_final=[95.0], settling_time=[0.5]) + await channels.dispense(ops, [0], lld_mode=[LLDMode.CAPACITIVE]) + await channels.dispense(ops, [0], command_version="v1") + """ + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld( + lld_mode, lld, len(ops), allowed_modes=_DISPENSE_ALLOWED_LLD + ) + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_dispense_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + c_lld=c_lld, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) + + 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 + try: + n = self._info.config.num_channels + except RuntimeError: + n = None + if n is not None and channel_idx >= n: + return False + return True + + # --------------------------------------------------------------------------- + # Firmware version queries (per-channel; box-level queries live on PrepClient) + # --------------------------------------------------------------------------- + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Firmware version string for pipettor channel (0=rearmost).""" + if channel >= len(self.channels): + return None + return await self.channels[channel].request_firmware_version() + + # --------------------------------------------------------------------------- + # Channel position queries + # --------------------------------------------------------------------------- + + async def request_channel_bounds(self) -> list[PrepChannelBounds]: + """Per-channel movement bounds (PipettorService.GetChannelBounds). + + Thin delegation to :func:`request_channel_bounds`. + Prefer reading cached values via ``self.channels[i].bounds``; use this when a + fresh re-query is required. + """ + return await request_channel_bounds(self._client) + + async def request_channel_positions(self) -> 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. + """ + try: + resp_obj = await self._client.send_command(PrepCmd.PrepGetPositions()) + except (HoiError, ChannelizedError): + return [] + if not isinstance(resp_obj, PrepCmd.PrepGetPositions.Response): + return [] + resp = resp_obj + if not resp.positions: + return [] + + _CHANNEL_ENUM_TO_IDX = {int(v): k for k, v in _CHANNEL_INDEX.items()} + indexed: list[tuple[int, Coordinate]] = [] + 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 float(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 float(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 float(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 + pipettor_addr = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + raw = await self._client.send_query( + PrepCmd.PrepProbeRequest(dest=pipettor_addr, command_id=13) + ) + 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 + + # --------------------------------------------------------------------------- + # 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 + # --------------------------------------------------------------------------- + + 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 lazily from the object tree and cached in + ``ChannelDriveMap``, 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=rearmost). True if tip detected. + """ + import struct as _struct + + drive_map = await self.discover_channel_drives() + if not drive_map.sleeve_sensor_addrs: + raise RuntimeError("No channel sleeve sensor addresses discovered.") + + results: list[bool] = [] + for addr in drive_map.sleeve_sensor_addrs: + raw = await self._client.send_query(PrepCmd.PrepProbeRequest(dest=addr, command_id=15)) + if raw is None or len(raw[0]) < 8: + results.append(False) + else: + val = _struct.unpack_from(" List[Optional[bool]]: + pres = await self.sense_tip_presence() + return [bool(x) for x in pres] + + # --------------------------------------------------------------------------- + # 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 PrepChannels." + ) + + 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 PrepChannels." + ) + + 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 + lld_mode=[LLDMode.CAPACITIVE] 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 PrepChannels." + ) + + 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 PrepChannels." + ) + + # --------------------------------------------------------------------------- + # 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=rearmost). 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(PrepCmd.PrepMoveZUpToSafe(channels=channel_enums)) + + async def move_to_position( + self, + x: float, + y: Union[float, List[float]], + z: Union[float, List[float]], + use_channels: Optional[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. + """ + 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, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + 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)" + + # 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}") + + move_parameters = _build_pipettor_gantry_move_parameters(x, channels, y, z) + + if via_lane: + await self._client.send_command( + PrepCmd.PrepMoveToPositionViaLane(move_parameters=move_parameters) + ) + else: + await self._client.send_command(PrepCmd.PrepMoveToPosition(move_parameters=move_parameters)) + + async def stop(self) -> None: + self.setup_finished = False + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "default_traverse_height": self._user_traverse_height, + "use_v1_aspirate_dispense": self._use_v1_aspirate_dispense, + } diff --git a/pylabrobot/hamilton/prep/chatterbox.py b/pylabrobot/hamilton/prep/chatterbox.py new file mode 100644 index 00000000000..4ff61522ba7 --- /dev/null +++ b/pylabrobot/hamilton/prep/chatterbox.py @@ -0,0 +1,183 @@ +"""PrepChatterboxClient: minimal client for tests without TCP hardware.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, List, Optional, Union + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.introspection import ( + HamiltonIntrospection, + MethodInfo, + ObjectInfo, +) +from pylabrobot.hamilton.transport.tcp.packets import Address + +from . import prep_commands as PrepCmd +from .client import ( + MLPREP_OBJECT_PATH, + MPH_OBJECT_PATH, + PIPETTOR_OBJECT_PATH, + PrepClient, +) +from .info import PrepInstrumentInfo +from .prep_commands import PrepCommand + +logger = logging.getLogger(__name__) + +# Channel v2 support probe expects pipettor interface 1 to expose these method IDs. +_V2_PIPETTING_METHOD_IDS = frozenset(range(38, 44)) +# PrepHead8._probe_v2_support expects MPH interface 1 to expose these method IDs. +_V2_MPH_METHOD_IDS = frozenset(range(29, 35)) + + +class _PrepChatterboxIntrospection(HamiltonIntrospection): + """Offline introspection: v2 probe succeeds when ``use_v1_aspirate_dispense`` is False.""" + + def __init__( + self, + *args, + stub_methods_fn: Callable[[Address, int], Optional[List[MethodInfo]]], + **kwargs, + ): + super().__init__(*args, **kwargs) + self._stub_methods_fn = stub_methods_fn + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + addr = await self._resolve_target_address(address) + stubs = self._stub_methods_fn(addr, interface_id) + if stubs is not None: + return stubs + return await super().methods_for_interface(address, interface_id) + + +class PrepChatterboxInstrumentInfo(PrepInstrumentInfo): + """Offline info: uses canned :class:`~prep_commands.InstrumentConfig` from the chatterbox client.""" + + async def _on_setup(self) -> None: + d = self._driver + assert isinstance(d, PrepChatterboxClient) + self._config = d._canned_config + + +class PrepChatterboxClient(PrepClient): + """Skips TCP; uses canned addresses so Prep channels can be exercised offline. + + Canned firmware state (num_channels, has_mph, traverse height) lives on the + chatterbox client — :class:`PrepChatterboxInstrumentInfo` reads it for ``info.config``. + + Default ``use_v1_aspirate_dispense=False`` matches hardware: introspection stubs + report v2 aspirate/dispense commands on the pipettor. Pass + ``use_v1_aspirate_dispense=True`` for a thinner v1-only offline path. + """ + + def __init__( + self, + num_channels: int = 2, + has_mph: bool = True, + default_traverse_height: float = 180.0, + use_v1_aspirate_dispense: bool = False, + ): + super().__init__(host="chatterbox", port=2000) + self._canned_config = PrepCmd.InstrumentConfig( + deck_bounds=None, + has_enclosure=False, + safe_speeds_enabled=True, + deck_sites=(), + waste_sites=(), + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + self._pipettor_addr: Optional[Address] = None + self._mph_addr: Optional[Address] = None + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + + @property + def introspection(self) -> HamiltonIntrospection: + if self._introspection_impl is None: + + def _stub_methods(addr: Address, interface_id: int) -> Optional[List[MethodInfo]]: + if interface_id == 1 and not self._use_v1_aspirate_dispense: + if self._pipettor_addr is not None and addr == self._pipettor_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_stub_{mid}") + for mid in sorted(_V2_PIPETTING_METHOD_IDS) + ] + if self._mph_addr is not None and addr == self._mph_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_mph_stub_{mid}") + for mid in sorted(_V2_MPH_METHOD_IDS) + ] + return None + + self._introspection_impl = _PrepChatterboxIntrospection( + registry=self._registry, + global_object_addresses=self._global_object_addresses, + send_discovery_command=self.send_discovery_command, + send_query=self.send_query, + stub_methods_fn=_stub_methods, + ) + return self._introspection_impl + + async def setup(self): + # Seed the introspection registry with every firmware path the codebase + # may touch. The seed list is derived from the command aggregate + # (PrepCommand._ALL_PATHS) plus PrepInstrumentInfo._paths — new commands + # with new firmware_path values get chatterbox parity for free. Addresses + # are assigned deterministically in sorted-path order so they're stable + # across runs. + seed_paths = sorted(PrepCommand._ALL_PATHS | set(PrepInstrumentInfo._paths.values())) + for idx, path in enumerate(seed_paths): + leaf = path.rsplit(".", 1)[-1] + addr = Address(1, 1, 256 + idx) + self.registry.register( + path, + ObjectInfo(name=leaf, version="", method_count=0, subobject_count=0, address=addr), + ) + self._pipettor_addr = await self.resolve_path(PIPETTOR_OBJECT_PATH) + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + if self._canned_config.has_mph: + self._mph_addr = await self.resolve_path(MPH_OBJECT_PATH) + + async def stop(self): + self._pipettor_addr = None + self._mph_addr = None + self._mlprep_address = None + self._invalidate_introspection_session() + + async def send_command( + self, + command: TCPCommand, + ensure_connection: bool = True, + return_raw: bool = False, + raise_on_error: bool = True, + read_timeout: Optional[float] = None, + ) -> Any: + del ensure_connection, raise_on_error, read_timeout + # Exercise the JIT resolve path so that missing firmware paths surface + # the same error offline as they would against hardware. + from .prep_commands import _UNRESOLVED, PrepCommand + + if isinstance(command, PrepCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path " + f"{path!r} did not resolve on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + logger.info("[Prep chatterbox] %s", command.__class__.__name__) + if return_raw: + return (b"",) + return None diff --git a/pylabrobot/hamilton/prep/client.py b/pylabrobot/hamilton/prep/client.py new file mode 100644 index 00000000000..6c673a94c29 --- /dev/null +++ b/pylabrobot/hamilton/prep/client.py @@ -0,0 +1,195 @@ +"""PrepClient: Hamilton TCP client for Hamilton Prep liquid handlers (Nimbus-style layout). + +Transport-only: opens TCP, discovers the firmware root, and resolves one bootstrap +handle — :attr:`PrepClient.mlprep_address` (``MLPrepRoot.MLPrep``). Everything +else uses :meth:`HamiltonTCPClient.resolve_path`, which consults the introspection +registry (cache-hot after the first hit). + +**JIT command targets.** Concrete :class:`~pylabrobot.hamilton.prep.prep_commands.PrepCommand` +subclasses declare ``firmware_path``; :meth:`PrepClient._send_raw` resolves +that path when ``dest`` is the unresolved sentinel. No parallel path tables on +backends. + +**Bootstrap info.** :class:`~pylabrobot.hamilton.prep.info.PrepInstrumentInfo` +resolves a small set of diagnostic paths (see ``PrepInstrumentInfo._paths``) +during setup via the same ``resolve_path`` cache. + +**Channel topology** (per-channel drive addresses) is discovered in +:mod:`~pylabrobot.hamilton.prep.channels` by walking the tree +from ``MLPrepRoot``, not via a separate registry. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.error_tables import PREP_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient + +from . import prep_commands as PrepCmd +from .prep_commands import _UNRESOLVED, PrepCommand + +logger = logging.getLogger(__name__) + +_EXPECTED_ROOT = "MLPrepRoot" + +# Canonical firmware path strings (single source for client, chatterbox, probes). +MLPREP_OBJECT_PATH = "MLPrepRoot.MLPrep" +PIPETTOR_OBJECT_PATH = "MLPrepRoot.PipettorRoot.Pipettor" +MPH_OBJECT_PATH = "MLPrepRoot.MphRoot.MPH" + + +class PrepClient(HamiltonTCPClient): + """Hamilton TCP client for Prep: connection, MLPrep bootstrap, firmware string decode. + + Instrument-wide motion, power, and deck-light entry points live on + :class:`~pylabrobot.hamilton.prep.prep.Prep` and + :class:`~pylabrobot.hamilton.prep.method.PrepMethodLifecycle`. + Pipettor, calibration, and MPH traffic goes through :class:`PrepCommand` plus + :meth:`send_command` / :meth:`resolve_path`, or through peers that build those + commands. + """ + + _ERROR_CODES = PREP_ERROR_CODES + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 300.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + connection_timeout: int = 600, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + connection_timeout=connection_timeout, + ) + self._mlprep_address: Optional[Address] = None + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + async def setup(self): + await super().setup() + + root = await self.discovered_root_name() + if root != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{root}'. Wrong instrument?" + ) + + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + + async def stop(self) -> None: + await super().stop() + self._mlprep_address = None + + # --------------------------------------------------------------------------- + # MLPrep root handle (resolved in :meth:`setup`) + # --------------------------------------------------------------------------- + + @property + def mlprep_address(self) -> Address: + """Address of ``MLPrepRoot.MLPrep``. Raises if :meth:`setup` has not run.""" + if self._mlprep_address is None: + raise RuntimeError("MLPrep address not resolved. Call setup() first.") + return self._mlprep_address + + # --------------------------------------------------------------------------- + # JIT firmware-path resolution for PrepCommand.dest + # --------------------------------------------------------------------------- + + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + if isinstance(command, PrepCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction. Polymorphic-dest commands " + "must pass dest= to send_query or send_command." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path " + f"{path!r} did not resolve on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + return await super()._send_raw( + command, + ensure_connection=ensure_connection, + return_raw=return_raw, + raise_on_error=raise_on_error, + read_timeout=read_timeout, + ) + + # --------------------------------------------------------------------------- + # Discovery + # --------------------------------------------------------------------------- + + async def discovered_root_name(self) -> str: + roots = self.get_root_object_addresses() + if not roots: + raise RuntimeError("No root objects discovered. Call setup() first.") + info = await self.introspection.get_object(roots[0]) + name = info.name + if not isinstance(name, str): + raise RuntimeError(f"Unexpected root name type: {type(name).__name__}") + return name + + # --------------------------------------------------------------------------- + # Firmware string queries (transport: raw HOI decode + status query) + # --------------------------------------------------------------------------- + + @staticmethod + def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: + """Decode a string from a raw HOI response (Hamilton string wire format).""" + 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.""" + ns: dict[str, Any] = { + "command_id": cmd_id, + "interface_id": iface_id, + "__annotations__": {"dest": Address}, + } + Cmd = type("_FWQuery", (PrepCmd.PrepStatusRequest,), ns) + raw_resp: object = await self.send_query(Cmd(dest=addr)) + if raw_resp is None: + return self._decode_firmware_string(None) + if not isinstance(raw_resp, tuple): + return None + return self._decode_firmware_string(raw_resp) diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py new file mode 100644 index 00000000000..730f6811da0 --- /dev/null +++ b/pylabrobot/hamilton/prep/gripper.py @@ -0,0 +1,302 @@ +"""Hamilton Prep CoRe gripper and PrepGripperArm frontend helper.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, Optional + +from pylabrobot.resources import Coordinate, Resource + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .channels import PrepChannels + from .client import PrepClient + +logger = logging.getLogger(__name__) + + +class PrepGripper: + """CoRe gripper for Prep — translates plate/tool ops to PrepCmd firmware commands. + + Tool management (pick_up_tool / drop_tool) is handled by the + :meth:`Prep.core_grippers` context manager. + """ + + def __init__(self, *, client: "PrepClient", channels: "PrepChannels") -> None: + self._client = client + self._channels = channels + + @property + def client(self) -> "PrepClient": + return self._client + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center at grip height (x, y, grip_z) in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm. + resource_length: Plate length (X) in mm. + resource_height: Plate height (Z) in mm. + plate_top_z_offset: Offset from grip Z to plate top center Z. + clearance_y: Approach clearance along the grip axis (mm). + grip_speed_y: Grip speed (mm/s). + squeeze_mm: Additional squeeze distance beyond clearance (mm). + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z + plate_top_z_offset, + ) + plate_dims = PrepCmd.PlateDimensions( + default_values=False, + length=resource_length, + width=resource_width, + height=resource_height, + ) + grip_distance = clearance_y + squeeze_mm + + await self._client.send_command( + PrepCmd.PrepPickUpPlate( + 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=location.z, + ) + ) + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center at place height in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm (unused by firmware). + clearance_y: Release clearance along the grip axis (mm). + acceleration_scale_x: X-axis acceleration scale. + """ + del resource_width + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.send_command( + PrepCmd.PrepDropPlate( + plate_top_center=plate_top_center, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position in deck coordinates. + acceleration_scale_x: X-axis acceleration scale. + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.send_command( + PrepCmd.PrepMovePlate( + plate_top_center=plate_top_center, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def release_plate(self) -> None: + """Open the CoRe gripper and release whatever is held (PrepReleasePlate, cmd=21).""" + await self._client.send_command(PrepCmd.PrepReleasePlate()) + + 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[PrepCmd.TipPickupParameters] = None, + ) -> None: + """Pick up CoRe gripper tool (PrepPickUpTool, cmd=15). Moves channels to safe Z after.""" + if tool_seek is None: + tool_seek = tool_position_z + 10.0 + if tip_definition is None: + tip_definition = PrepCmd.CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + await self._client.send_command( + PrepCmd.PrepPickUpTool( + 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, + ) + ) + await self._channels.move_channels_to_safe_z() + + async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: + """Drop CoRe gripper tool (PrepDropTool, cmd=16).""" + if move_to_safe_z_first: + await self._channels.move_channels_to_safe_z() + await self._client.send_command(PrepCmd.PrepDropTool()) + + +class PrepGripperArm: + """Thin helper that auto-populates Prep firmware geometry from the target resource. + + When ``pick_up_resource()`` is called, resource dimensions (length, height) and the + plate-top Z offset are extracted from the :class:`Resource` automatically. Users + only need to pass firmware tuning knobs (``clearance_y``, ``grip_speed_y``, + ``squeeze_mm``). + """ + + def __init__( + self, + backend: PrepGripper, + reference_resource: Resource, + grip_axis: Literal["x", "y"] = "y", + ) -> None: + self.backend = backend + self._reference_resource = reference_resource + self._grip_axis = grip_axis + self._pickup_distance_from_bottom: Optional[float] = None + self._holding_resource_width: Optional[float] = None + self._held_resource: Optional[Resource] = None + + def _resolve_pickup_distance( + self, resource: Resource, pickup_distance_from_bottom: Optional[float] + ) -> float: + if pickup_distance_from_bottom is not None: + return pickup_distance_from_bottom + if resource.preferred_pickup_location is not None: + logger.debug( + "Using preferred pickup location for resource %s as pickup_distance_from_bottom was " + "not specified.", + resource.name, + ) + return resource.preferred_pickup_location.z + logger.debug( + "No preferred pickup location for resource %s. Using default pickup distance of 5mm " + "from top (= size_z - 5).", + resource.name, + ) + return resource.get_size_z() - 5.0 + + def _pickup_location( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_bottom: float, + ) -> Coordinate: + center = resource.center().rotated(resource.get_absolute_rotation()) + if resource.is_in_subtree_of(self._reference_resource): + loc = resource.get_location_wrt(self._reference_resource, "l", "f", "b") + center + offset + else: + loc = center + offset + return Coordinate(loc.x, loc.y, loc.z + pickup_distance_from_bottom) + + def _resource_width(self, resource: Resource) -> float: + if self._grip_axis == "y": + return resource.get_absolute_size_y() + return resource.get_absolute_size_x() + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + *, + resource_length: Optional[float] = None, + resource_height: Optional[float] = None, + plate_top_z_offset: Optional[float] = None, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + pdfb = self._resolve_pickup_distance(resource, pickup_distance_from_bottom) + if resource_length is None: + resource_length = resource.get_absolute_size_x() + if resource_height is None: + resource_height = resource.get_absolute_size_z() + if plate_top_z_offset is None: + plate_top_z_offset = resource.get_absolute_size_z() - pdfb + + location = self._pickup_location(resource, offset, pdfb) + resource_width = self._resource_width(resource) + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._pickup_distance_from_bottom = pdfb + self._holding_resource_width = resource_width + self._held_resource = resource + + async def drop_at_location( + self, + location: Coordinate, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._holding_resource_width = None + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + await self.backend.move_to_location(location, acceleration_scale_x=acceleration_scale_x) diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py new file mode 100644 index 00000000000..74711d18d46 --- /dev/null +++ b/pylabrobot/hamilton/prep/head8.py @@ -0,0 +1,1046 @@ +"""PrepHead8 — 8MPH head for the Hamilton Prep. + +The 8MPH is a ganged head: a single X/Y/Z gantry and a single dispenser piston +drive all 8 probes together. Individual sleeves are mechanically coupled — partial +sleeve engagement produces insufficient grip force and tips fall off. All +operations therefore require all 8 channels simultaneously. + +------------------------------ +- PickupTips / DropTips: single TipPositionParameters struct; Y = probe-0 reference. + PickupTips has tipMask (0xFF default) for Hamilton service tooling; DropTips + has NO tip mask — all probes drop together unconditionally. +- Aspirate / Dispense: StructArray with exactly ONE entry. The gantry moves to + the probe-0 (row A) reference position and all 8 probes operate simultaneously. + Channel field = ChannelIndex.MPHChannel. + +Physical arrangement +-------------------- +Probes are ordered by Y (highest Y = probe 0 = row A). Pitch = PROBE_PITCH_MM. +""" + +from __future__ import annotations + +import logging +import struct as _struct +from typing import TYPE_CHECKING, List, Literal, Optional, Union + +from pylabrobot.resources import Trash + +from . import prep_commands as PrepCmd +from .channels import ( + LLDMode, + _absolute_z_from_well, + _build_container_segments, + _effective_radius, + _LldDefaults, +) +from .channels import ( + default_lld_params as _default_lld_params_fn, +) +from .channels import ( + lld_for_well as _lld_for_well_fn, +) +from .channels import ( + lld_seek_timeout as _lld_seek_timeout, +) +from .channels import ( + patch_common_with_cone as _patch_common_with_cone_fn, +) +from .channels import ( + resolve_command_version as _resolve_command_version_fn, +) +from .client import MPH_OBJECT_PATH +from .standard import ( + Head8AspirationContainer, + Head8AspirationWells, + Head8DispenseContainer, + Head8DispenseWells, + Head8TipDrop, + Head8TipPickup, +) + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +PROBE_PITCH_MM: float = 9.0 +NUM_PROBES: int = 8 +_FULL_TIP_MASK: int = 0xFF +_V2_MPH_CMD_IDS: frozenset = frozenset({29, 30, 31, 32, 33, 34}) +_PROBE_POS_TOLERANCE_MM: float = 1.0 # max deviation from expected 9mm pitch before raising + + +class PrepHead8: + """8-channel Multi-Pipetting Head for the Hamilton Prep. + + All 8 probes must participate in every operation. Partial channel selection + is rejected at this layer because the head is physically ganged (single drive + per axis, single piston) and partial sleeve engagement produces insufficient + grip force. + """ + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.MphAspirateWithLldTadm2, + (True, True, False): PrepCmd.MphAspirateWithLldTadm, + (True, False, True): PrepCmd.MphAspirateWithLld2, + (True, False, False): PrepCmd.MphAspirateWithLld, + (False, True, True): PrepCmd.MphAspirateTadm2, + (False, True, False): PrepCmd.MphAspirateTadm, + (False, False, True): PrepCmd.MphAspirateNoLldMonitoring2, + (False, False, False): PrepCmd.MphAspirateNoLldMonitoring, + } + + # Command dispatch tables: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.MphDispenseWithLld2, + (True, False): PrepCmd.MphDispenseWithLld, + (False, True): PrepCmd.MphDispenseNoLld2, + (False, False): PrepCmd.MphDispenseNoLld, + } + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self._user_traverse_height = default_traverse_height + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self.channels: list = [] # populated by build_prep_channels after construction + self._supports_v2_pipetting: Optional[bool] = None + + # --------------------------------------------------------------------------- + # Setup / V2 probing + # --------------------------------------------------------------------------- + + async def _probe_v2_support(self) -> bool: + """Return True if the MPH firmware exposes V2 aspirate/dispense (cmds 29-34).""" + dest = await self._client.resolve_path(MPH_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return _V2_MPH_CMD_IDS.issubset(iface1_ids) + + async def _on_setup(self) -> None: + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("MPH V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("MPH V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 29-34) are not supported by this MPH firmware. " + "Pass use_v1_aspirate_dispense=True to PrepHead8 to use v1 commands instead." + ) + self._supports_v2_pipetting = True + logger.info("MPH V2 aspirate/dispense support: True") + + async def _on_stop(self) -> None: + self._supports_v2_pipetting = None + + # --------------------------------------------------------------------------- + # Internal helpers + # --------------------------------------------------------------------------- + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return _resolve_command_version_fn( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 29-34) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepHead8." + ), + ) + + def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + height: Optional[float] = self._info.config.default_traverse_height + if height is None: + raise RuntimeError("No traverse height available; set default_traverse_height") + return height + + def _resolve_probe_positions(self, wells) -> List[float]: + """Compute expected probe Y positions and validate actual well Ys match. + + Probe 0 = row A = highest Y. Expected position for probe i: + wells[0].y - i * PROBE_PITCH_MM + + Works for any labware at 9mm pitch: standard 96-well columns, or + interleaved 384-well selections (every other row = 2 × 4.5mm = 9mm). + + Returns the expected Y values (one per probe) for logging/accounting. + Raises ValueError if any well deviates beyond _PROBE_POS_TOLERANCE_MM. + """ + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + expected_ys = [ref_y - i * PROBE_PITCH_MM for i in range(len(wells))] + + mismatches = [] + for i, (well, exp_y) in enumerate(zip(wells, expected_ys)): + actual_y = well.get_absolute_location("c", "c", "cavity_bottom").y + if abs(actual_y - exp_y) > _PROBE_POS_TOLERANCE_MM: + mismatches.append( + f" probe {i} ({well.name}): expected y={exp_y:.2f}, actual y={actual_y:.2f}" + ) + + if mismatches: + actual_ys = [round(w.get_absolute_location("c", "c", "cavity_bottom").y, 2) for w in wells] + raise ValueError( + f"Wells are not at {PROBE_PITCH_MM} mm probe pitch from wells[0]. " + f"Pass wells in row-A-first order at {PROBE_PITCH_MM} mm spacing " + f"(for 384-well plates: every other row).\n" + + "\n".join(mismatches) + + f"\nActual Y values: {actual_ys}" + ) + + return expected_ys + + def _validate_container_span(self, container) -> None: + """Raise ValueError if the container is too narrow for all 8 probes. + + Minimum Y span = (NUM_PROBES - 1) * PROBE_PITCH_MM = 63 mm. + """ + min_span = (NUM_PROBES - 1) * PROBE_PITCH_MM + span = container.get_size_y() + if span < min_span: + raise ValueError( + f"Container '{container.name}' Y span ({span:.1f} mm) is too narrow for " + f"{NUM_PROBES} probes at {PROBE_PITCH_MM} mm pitch " + f"(minimum {min_span:.1f} mm required)." + ) + + def _require_all_channels(self, use_channels: List[int], op: str) -> None: + """Raise ValueError unless use_channels is exactly [0..7]. + + The 8MPH is a ganged head — all 8 probes must participate in every operation. + Partial channel selection produces insufficient tip grip force (physical + constraint confirmed via firmware/hardware inspection). + """ + if list(use_channels) != list(range(NUM_PROBES)): + raise ValueError( + f"PrepHead8.{op}: the 8MPH is a fully-ganged head — all {NUM_PROBES} " + f"channels must participate. Received use_channels={use_channels}. " + "Partial tip pickup/drop/aspirate/dispense is not physically supported." + ) + + def _resolve_effective_lld( + self, + lld_mode: Optional[LLDMode], + lld: Optional[PrepCmd.LldParameters], + *, + allowed_modes: Optional[frozenset] = None, + ) -> bool: + """Determine whether LLD is active for this MPH pipetting call. + + Unlike the PIP backend (which takes a per-channel list), the MPH accepts a + single LLDMode because the ganged head operates as one unit. + """ + if lld_mode is not None: + if lld_mode != LLDMode.OFF: + if allowed_modes is not None and lld_mode not in allowed_modes: + raise ValueError( + f"Dispense does not support {lld_mode.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + return True + return False + return lld is not None + + # --------------------------------------------------------------------------- + # Aspirate assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_aspirate_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + def _assemble_aspirate_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + # --------------------------------------------------------------------------- + # Dispense assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_dispense_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + def _assemble_dispense_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + # --------------------------------------------------------------------------- + # MPH gantry (IMph MoveToPosition) + # --------------------------------------------------------------------------- + + async def move_to_position( + self, + x: float, + y: float, + z: float, + *, + via_lane: bool = False, + ) -> None: + """Move the ganged 8-channel head to absolute deck ``(x, y, z)`` (mm). + + Sends :class:`~prep_commands.MphMoveToPosition` or + :class:`~prep_commands.MphMoveToPositionViaLane` on ``MLPrepRoot.MphRoot.MPH``. + One pose for the whole head — unlike independent-channel ``move_to_position``, + there are no per-channel ``y``/``z`` lists. + + Args: + x: Gantry X. + y: Gantry Y at the probe-0 (row A) reference. + z: Z height (e.g. traverse). + via_lane: Use lane-aware move when True. + """ + if via_lane: + await self._client.send_command( + PrepCmd.MphMoveToPositionViaLane(x_position=x, y_position=y, z_position=z) + ) + else: + await self._client.send_command( + PrepCmd.MphMoveToPosition(x_position=x, y_position=y, z_position=z) + ) + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense + # --------------------------------------------------------------------------- + + async def pick_up_tips8( + self, + op: Head8TipPickup, + *, + 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, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ) -> None: + use_channels = list(op.use_channels) + self._require_all_channels(use_channels, "pick_up_tips8") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = op.tip_spots[0] + rack = ref_spot.parent + logger.info( + "[Prep MPH] pick_up_tips: rack=%s, tip_spots=%s", + rack.name if rack is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in op.tip_spots], + ) + # Use the tip from the struct — the spot tracker is already cleared by Head8 before + # this method is invoked, so ref_spot.get_tip() would fail. + tip = op.tips[0] + if tip is None: + raise RuntimeError("pick_up_tips8: first spot has no tip") + loc = ref_spot.get_absolute_location("c", "c", "t") + + # Pre-position uses the same absolute frame as tip_position below (probe 0 / row A). + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + await self.move_to_position(loc.x, loc.y, traverse_h) + + # tip_spots[0] is always row-A (probe 0, highest Y) since all 8 channels are required. + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset + ) + tip_definition = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + await self._client.send_command( + PrepCmd.MphPickupTips( + tip_position=tip_position, + 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=_FULL_TIP_MASK, + ) + ) + + async def drop_tips8( + self, + op: Head8TipDrop, + *, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + tip_roll_off_distance: float = 0.0, + ) -> None: + use_channels = list(op.use_channels) + self._require_all_channels(use_channels, "drop_tips8") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = op.resources[0] + is_trash = isinstance(ref_spot, Trash) + dest = ref_spot if is_trash else ref_spot.parent + logger.info( + "[Prep MPH] drop_tips: dest=%s, resources=%s", + dest.name if dest is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in op.resources], + ) + tip = op.tips[0] + if tip is None: + raise RuntimeError("drop_tips8: no tip on first channel") + + # resources[0] = probe 0 (row A, highest Y). Use "c","c","t" consistently for + # both tip spots and trash — matches PrepChannels.drop_tips and TipDropParameters.for_op. + loc = ref_spot.get_absolute_location("c", "c", "t") + if not is_trash: + loc = loc + op.offset + drop_type = PrepCmd.TipDropType.Stall if is_trash else PrepCmd.TipDropType.FixedHeight + + tip_position = PrepCmd.TipDropParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) + roll_off = 3.0 if (is_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + await self._client.send_command( + PrepCmd.MphDropTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + async def aspirate8( + self, + op: Union[Head8AspirationWells, Head8AspirationContainer], + *, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + prewet_volume: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + 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[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + use_channels = list(op.use_channels) + self._require_all_channels(use_channels, "aspirate8") + tip = next((t for t in op.tips if t is not None), None) + traverse_z = self._resolve_traverse_height() + final_z = ( + z_final + if z_final is not None + else ( + traverse_z - (tip.total_tip_length - tip.fitting_depth) if tip is not None else traverse_z + ) + ) + + op_targets: Union[str, List[str]] + if isinstance(op, Head8AspirationContainer): + container = op.container + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, op.liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells = op.wells + self._resolve_probe_positions(wells) # validates 9mm pitch; raises on mismatch + resource_name = wells[0].parent.name if wells[0].parent is not None else wells[0].name + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells] + ref_loc = wells[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells[0], op.liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells[0]) if auto_container_geometry else [] + ) + ref_resource = wells[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = settling_time if settling_time is not None else 1.0 + resolved_transport_air_volume = ( + transport_air_volume if transport_air_volume is not None else 0.0 + ) + resolved_z_liquid_exit_speed = z_liquid_exit_speed if z_liquid_exit_speed is not None else 10.0 + resolved_prewet_volume = prewet_volume if prewet_volume is not None else 0.0 + blowout_volume = op.blow_out_air_volume or 0.0 + + logger.info( + "[Prep MPH] aspirate: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + op.volume, + round(op.flow_rate, 3) if op.flow_rate is not None else None, + ) + + tube_radius = _effective_radius(ref_resource) + effective_lld = self._resolve_effective_lld(lld_mode, lld) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, p_lld, c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + resolved_tadm = tadm or PrepCmd.TadmParameters.default() + + assemble = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=op.volume, + tube_radius=tube_radius, + final_z=final_z, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + prewet_volume=resolved_prewet_volume, + blowout_volume=blowout_volume, + flow_rate=op.flow_rate, + segments=ref_segments, + effective_lld=effective_lld, + is_tadm=is_tadm, + lld_params=lld_params, + lld_defaults=lld_defaults, + tadm=resolved_tadm, + ) + + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + await self._client.send_command( + cmd_cls(aspirate_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + async def dispense8( + self, + op: Union[Head8DispenseWells, Head8DispenseContainer], + *, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + stop_back_volume: Optional[float] = None, + cutoff_speed: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + use_channels = list(op.use_channels) + self._require_all_channels(use_channels, "dispense8") + tip = next((t for t in op.tips if t is not None), None) + traverse_z = self._resolve_traverse_height() + final_z = ( + z_final + if z_final is not None + else ( + traverse_z - (tip.total_tip_length - tip.fitting_depth) if tip is not None else traverse_z + ) + ) + + op_targets: Union[str, List[str]] + if isinstance(op, Head8DispenseContainer): + container = op.container + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, op.liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells = op.wells + self._resolve_probe_positions(wells) # validates 9mm pitch; raises on mismatch + resource_name = wells[0].parent.name if wells[0].parent is not None else wells[0].name + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells] + ref_loc = wells[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells[0], op.liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells[0]) if auto_container_geometry else [] + ) + ref_resource = wells[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = settling_time if settling_time is not None else 0.0 + resolved_transport_air_volume = ( + transport_air_volume if transport_air_volume is not None else 0.0 + ) + resolved_z_liquid_exit_speed = z_liquid_exit_speed if z_liquid_exit_speed is not None else 10.0 + resolved_stop_back_volume = stop_back_volume if stop_back_volume is not None else 0.0 + resolved_cutoff_speed = cutoff_speed if cutoff_speed is not None else 100.0 + + logger.info( + "[Prep MPH] dispense: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + op.volume, + round(op.flow_rate, 3) if op.flow_rate is not None else None, + ) + + tube_radius = _effective_radius(ref_resource) + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld(lld_mode, lld, allowed_modes=_DISPENSE_ALLOWED_LLD) + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, c_lld=c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + + assemble = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=op.volume, + tube_radius=tube_radius, + final_z=final_z, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + stop_back_volume=resolved_stop_back_volume, + cutoff_speed=resolved_cutoff_speed, + flow_rate=op.flow_rate, + segments=ref_segments, + effective_lld=effective_lld, + lld_params=lld_params, + lld_defaults=lld_defaults, + ) + + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + await self._client.send_command( + cmd_cls(dispense_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Sense whether tips are present on the 8MPH head via the sleeve sensor (cmd=15). + + The 8MPH is a single ganged controller — the firmware tree exposes one sleeve + sensor node (on the probe-0 / channel-0 entry). The result is broadcast across + all 8 positions since the head picks up and drops all probes together. + + Returns: + 8-element list. True=tips detected, False=no tips, None=sensor unavailable. + """ + if not self.channels: + raise RuntimeError("MPH channels not populated; call build_prep_channels first.") + + addr = getattr(self.channels[0], "sleeve_sensor", None) + if addr is None: + return [None] * NUM_PROBES + + raw = await self._client.send_query(PrepCmd.PrepProbeRequest(dest=addr, command_id=15)) + if raw is None or len(raw[0]) < 8: + result = False + else: + val = _struct.unpack_from(" Address: + """Resolve a diagnostic path alias; raises if absent.""" + if key not in self._paths: + raise KeyError(f"unknown info path key: {key!r}") + return await self._driver.resolve_path(self._paths[key]) + + async def _try_require(self, key: str) -> Optional[Address]: + """Resolve a diagnostic path alias; returns ``None`` if the path is absent.""" + try: + return await self._require(key) + except (KeyError, RuntimeError, TypeError): + return None + + # -- Lifecycle -------------------------------------------------------------- + + async def _on_setup(self) -> None: + """Fetch and cache the instrument config. Called from :meth:`Prep.setup`.""" + self._config = await self._load_instrument_config() + + async def _on_stop(self) -> None: + self._config = None + + # -- Cached config ---------------------------------------------------------- + + @property + def config(self) -> PrepCmd.InstrumentConfig: + """Cached ``InstrumentConfig``. Raises if ``_on_setup`` has not run.""" + if self._config is None: + raise RuntimeError("Instrument config not available. Call Prep.setup() first.") + return self._config + + @property + def num_channels(self) -> int: + n = self.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + @property + def deck_bounds(self) -> Optional[PrepCmd.DeckBounds]: + return self.config.deck_bounds + + @property + def deck_sites(self) -> Tuple[PrepCmd.DeckSiteInfo, ...]: + return self.config.deck_sites + + @property + def waste_sites(self) -> Tuple[PrepCmd.WasteSiteInfo, ...]: + return self.config.waste_sites + + @property + def default_traverse_height(self) -> Optional[float]: + return self.config.default_traverse_height + + @property + def has_enclosure(self) -> bool: + return self.config.has_enclosure + + @property + def safe_speeds_enabled(self) -> bool: + return self.config.safe_speeds_enabled + + async def refresh(self) -> PrepCmd.InstrumentConfig: + """Re-query instrument config and update the cached snapshot.""" + self._config = await self._load_instrument_config() + return self._config + + # -- Instrument config (MLPrep / deck / service) ---------------------------- + + async def get_present_channels(self) -> Optional[Tuple[PrepCmd.ChannelIndex, ...]]: + """Query which channels are present (GetPresentChannels on MLPrepService).""" + d = self._driver + service_addr = await self._try_require("mlprep_service") + if service_addr is None: + return None + try: + resp = await d.send_command(PrepCmd.PrepGetPresentChannels(dest=service_addr)) + if resp is None or not getattr(resp, "channels", None): + return None + return tuple( + PrepCmd.ChannelIndex(v) if v in (0, 1, 2, 3) else PrepCmd.ChannelIndex.InvalidIndex + for v in resp.channels + ) + except ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, + ): + raise + except Exception as e: + logger.warning("Failed to query present channels: %s", e) + return None + + async def _load_instrument_config(self) -> PrepCmd.InstrumentConfig: + """Aggregate MLPrep, DeckConfiguration, and MLPrepService into ``InstrumentConfig``.""" + d = self._driver + mlprep = d.mlprep_address + enc_resp = await d.send_command(PrepCmd.PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await d.send_command(PrepCmd.PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await d.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[PrepCmd.DeckBounds] = None + deck_sites: Tuple[PrepCmd.DeckSiteInfo, ...] = () + waste_sites: Tuple[PrepCmd.WasteSiteInfo, ...] = () + deck_addr = await self._try_require("deck_config") + if deck_addr is None: + raise RuntimeError("DeckConfiguration path did not resolve — cannot load instrument config") + + bounds_resp = await d.send_command(PrepCmd.PrepGetDeckBounds(dest=deck_addr)) + if bounds_resp: + deck_bounds = PrepCmd.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 d.send_command(PrepCmd.PrepGetDeckSiteDefinitions(dest=deck_addr)) + if sites_resp and sites_resp.sites: + deck_sites = tuple( + 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), + 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.debug("Discovered %d deck sites", len(deck_sites)) + + waste_resp = await d.send_command(PrepCmd.PrepGetWasteSiteDefinitions(dest=deck_addr)) + if waste_resp and waste_resp.sites: + waste_sites = tuple( + PrepCmd.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.debug("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + + present = await self.get_present_channels() + if present is not None: + dual = [ + c + for c in present + if c in (PrepCmd.ChannelIndex.FrontChannel, PrepCmd.ChannelIndex.RearChannel) + ] + num_channels = len(dual) + has_mph = PrepCmd.ChannelIndex.MPHChannel in present + else: + num_channels = 2 + has_mph = False + + return PrepCmd.InstrumentConfig( + deck_bounds=deck_bounds, + has_enclosure=has_enclosure, + safe_speeds_enabled=safe_speeds_enabled, + deck_sites=deck_sites, + waste_sites=waste_sites, + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + + async def is_initialized(self) -> bool: + """Whether MLPrep reports as initialized (GetIsInitialized, cmd=2).""" + result = await self._driver.send_command( + PrepCmd.PrepGetIsInitialized(dest=self._driver.mlprep_address) + ) + if result is None: + return False + return bool(result.value) + + async def get_tip_and_needle_definitions(self) -> Tuple[PrepCmd.TipDefinition, ...]: + """Tip/needle definitions (GetTipAndNeedleDefinitions, cmd=11).""" + result = await self._driver.send_command( + PrepCmd.PrepGetTipAndNeedleDefinitions(dest=self._driver.mlprep_address) + ) + if result is None or not getattr(result, "definitions", None): + return () + return tuple(result.definitions) + + # -- Firmware string queries (orchestration; decode on PrepClient) ---------- + + async def get_firmware_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=8) + + async def get_device_serial_number(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=9) + + async def get_bootloader_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=2, iface_id=2) + + async def get_module_part_number(self) -> Optional[str]: + addr = await self._try_require("module_information") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=5) + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Firmware object tree. ``print(await info.get_firmware_tree())`` for a diagnostic dump.""" + return await self._driver.introspection.get_firmware_tree(refresh=refresh) diff --git a/pylabrobot/hamilton/prep/method.py b/pylabrobot/hamilton/prep/method.py new file mode 100644 index 00000000000..82fe89d2209 --- /dev/null +++ b/pylabrobot/hamilton/prep/method.py @@ -0,0 +1,55 @@ +"""Prep method lifecycle service. + +Owns MLPrep method commands (``PrepMethodBegin`` / ``PrepMethodEnd`` / ``PrepMethodAbort``) +via ``PrepClient`` transport, and exposes an async context manager +(:meth:`PrepMethodLifecycle.run`) that calls ``abort`` on exception and ``end`` on +clean exit — mirrors the ``Prep.core_grippers()`` pattern in ``prep.py``. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, AsyncIterator + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + + +class PrepMethodLifecycle: + """Method begin/end/abort + ``async with`` safety net.""" + + def __init__(self, driver: "PrepClient"): + self._driver = driver + + async def begin(self, automatic_pause: bool = False) -> None: + """Signal the start of a liquid-handling method.""" + await self._driver.send_command(PrepCmd.PrepMethodBegin(automatic_pause=automatic_pause)) + + async def end(self) -> None: + """Signal the end of a liquid-handling method.""" + await self._driver.send_command(PrepCmd.PrepMethodEnd()) + + async def abort(self) -> None: + """Abort the current method.""" + await self._driver.send_command(PrepCmd.PrepMethodAbort()) + + @asynccontextmanager + async def run(self, automatic_pause: bool = False) -> AsyncIterator["PrepMethodLifecycle"]: + """Bracket a liquid-handling block with ``begin`` / ``end``; ``abort`` on exception. + + Usage:: + + async with prep.method.run(): + await prep.pip.pick_up_tips(...) + await prep.pip.aspirate(...) + """ + await self.begin(automatic_pause=automatic_pause) + try: + yield self + except BaseException: + await self.abort() + raise + else: + await self.end() diff --git a/pylabrobot/hamilton/prep/prep.py b/pylabrobot/hamilton/prep/prep.py new file mode 100644 index 00000000000..5e62ccc5b48 --- /dev/null +++ b/pylabrobot/hamilton/prep/prep.py @@ -0,0 +1,269 @@ +"""Prep device: orchestrates transport, instrument info, and peer construction.""" + +from __future__ import annotations + +import asyncio +import logging +import random +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional, Tuple + +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers + +from . import prep_commands as PrepCmd +from .calibration import PrepCalibration +from .channels import PrepChannels, build_prep_channels +from .chatterbox import PrepChatterboxClient, PrepChatterboxInstrumentInfo +from .client import PrepClient +from .gripper import PrepGripper, PrepGripperArm +from .head8 import PrepHead8 +from .info import PrepInstrumentInfo +from .method import PrepMethodLifecycle + +logger = logging.getLogger(__name__) + + +class Prep: + """Hamilton Prep liquid handler. + + Setup constructs peers (``channels``, ``head8``, ``method``, ``calibration``, + gripper factory) directly. Firmware paths live on each :class:`PrepCommand` + subclass and are resolved JIT by :meth:`PrepClient.send_command`. + """ + + def __init__( + self, + deck: Deck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + client: PrepClient = PrepChatterboxClient() + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + client = PrepClient(host=host, port=port) + self.client: PrepClient = client + self.deck = deck + self.info = PrepChatterboxInstrumentInfo(client) if chatterbox else PrepInstrumentInfo(client) + self._core_gripper_arm: Optional[PrepGripperArm] = None + self.channels: Optional[PrepChannels] = None + self.head8: Optional[PrepHead8] = None + self.gripper: Optional[PrepGripper] = None + self.method: Optional[PrepMethodLifecycle] = None + self.calibration: Optional[PrepCalibration] = None + self._setup_finished: bool = False + + async def setup( + self, + *, + smart: bool = True, + force_initialize: bool = False, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ): + """Connect, bootstrap info, initialize MLPrep, construct peers.""" + try: + await self.client.setup() + await self.info._on_setup() + await self._initialize_instrument(smart=smart, force_initialize=force_initialize) + + self.method = PrepMethodLifecycle(self.client) + self.calibration = PrepCalibration(driver=self.client, info=self.info) + channels = PrepChannels( + client=self.client, + info=self.info, + deck=self.deck, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + channels.channels = await build_prep_channels(self.client, self.info) + self.channels = channels + await channels._on_setup() + + if channels.has_mph: + head8 = PrepHead8( + client=self.client, + info=self.info, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + head8.channels = await build_prep_channels( + self.client, self.info, root_name="MPH Channel Root", num_channels=8 + ) + self.head8 = head8 + await head8._on_setup() + + self.gripper = PrepGripper(client=self.client, channels=channels) + self._setup_finished = True + except Exception: + await self.info._on_stop() + await self.client.stop() + raise + + async def _initialize_instrument(self, *, smart: bool, force_initialize: bool) -> None: + """Send ``MLPrep.Initialize`` when needed.""" + if not force_initialize: + try: + already = await self.info.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") + return + + await self.client.send_command( + PrepCmd.PrepInitialize( + smart=smart, + tip_drop_params=PrepCmd.InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + ) + ) + logger.info( + "Prep initialization complete%s", + " (force_initialize=True)" if force_initialize else "", + ) + + async def stop(self): + if not self._setup_finished: + return + if self._core_gripper_arm is not None: + logger.warning( + "Prep.stop() called with CoRe grippers still mounted. " + "stop() only manages connection teardown and will NOT move the instrument. " + "Call `await prep.return_core_grippers()` first if you want the tools returned." + ) + self._core_gripper_arm = None + if self.channels is not None: + await self.channels._on_stop() + if self.head8 is not None: + await self.head8._on_stop() + await self.client.stop() + await self.info._on_stop() + self.channels = None + self.head8 = None + self.gripper = None + self.method = None + self.calibration = None + self._setup_finished = False + + # -- CoRe grippers ----------------------------------------------------------- + + @property + def core_gripper_arm(self) -> PrepGripperArm: + """The mounted CoRe gripper arm. Raises if grippers are not currently picked up.""" + if self._core_gripper_arm is None: + raise RuntimeError( + "CoRe grippers not mounted. Call `await prep.pick_up_core_grippers()` first, " + "or use `async with prep.core_grippers() as arm:`." + ) + return self._core_gripper_arm + + @property + def core_grippers_mounted(self) -> bool: + return self._core_gripper_arm is not None + + async def pick_up_core_grippers(self) -> PrepGripperArm: + """Pick up the CoRe gripper tools and return the mounted arm.""" + if self._core_gripper_arm is not None: + raise RuntimeError("CoRe grippers already mounted") + if self.channels is None or self.gripper is None: + raise RuntimeError("Prep.setup() has not run.") + + 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.gripper.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._core_gripper_arm = PrepGripperArm( + backend=self.gripper, reference_resource=self.deck, grip_axis="y" + ) + return self._core_gripper_arm + + async def return_core_grippers(self) -> None: + if self._core_gripper_arm is None: + return + try: + await self._core_gripper_arm.backend.drop_tool() + finally: + self._core_gripper_arm = None + + @asynccontextmanager + async def core_grippers(self) -> AsyncIterator[PrepGripperArm]: + arm = await self.pick_up_core_grippers() + try: + yield arm + finally: + await self.return_core_grippers() + + # -- Motion, power, lights (MLPrep via client transport) -------------------- + + async def park(self) -> None: + await self.client.send_command(PrepCmd.PrepPark()) + + async def spread(self) -> None: + await self.client.send_command(PrepCmd.PrepSpread()) + + async def is_parked(self) -> bool: + result = await self.client.send_command(PrepCmd.PrepIsParked()) + if result is None: + return False + return bool(result.value) + + async def is_spread(self) -> bool: + result = await self.client.send_command(PrepCmd.PrepIsSpread()) + if result is None: + return False + return bool(result.value) + + async def power_down_request(self) -> None: + await self.client.send_command(PrepCmd.PrepPowerDownRequest()) + + async def confirm_power_down(self) -> None: + await self.client.send_command(PrepCmd.PrepConfirmPowerDown()) + + async def cancel_power_down(self) -> None: + await self.client.send_command(PrepCmd.PrepCancelPowerDown()) + + async def get_deck_light(self) -> Tuple[int, int, int, int]: + result = await self.client.send_command(PrepCmd.PrepGetDeckLight()) + 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: + await self.client.send_command( + PrepCmd.PrepSetDeckLight(white=white, red=red, green=green, blue=blue) + ) + + 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) diff --git a/pylabrobot/hamilton/prep/prep_commands.py b/pylabrobot/hamilton/prep/prep_commands.py new file mode 100644 index 00000000000..95f902abed7 --- /dev/null +++ b/pylabrobot/hamilton/prep/prep_commands.py @@ -0,0 +1,2621 @@ +"""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 Prep channels / head8 peers for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +import datetime +import math +from dataclasses import dataclass, field, fields +from enum import IntEnum +from typing import Annotated, ClassVar, Optional, Set, Tuple + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol, Hoi2Action +from pylabrobot.hamilton.transport.tcp.wire_types import ( + F32, + I8, + I16, + U16, + U32, + EnumArray, + HcResultEntry, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, + U32Array, +) +from pylabrobot.hamilton.transport.tcp.wire_types import ( + Enum as WEnum, +) + +from .standard import Aspiration + +# ============================================================================= +# 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 + + +# ============================================================================= +# 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 +class HoiDateTime: + """Hamilton network/built-in dateTime struct (source_id=3, ref_id=3). + + Wire format: 7 DataFragments — year(U16), month(PaddedU8), day(PaddedU8), + hour(PaddedU8), minute(PaddedU8), second(PaddedU8), millisecond(U16). + + Used by EndCalibration and SetChannelHardwareConfiguration to timestamp + calibration data. Construct from ``datetime.datetime`` via ``from_datetime()``. + """ + + year: U16 + month: PaddedU8 + day: PaddedU8 + hour: PaddedU8 + minute: PaddedU8 + second: PaddedU8 + millisecond: U16 + + @classmethod + def from_datetime(cls, dt: datetime.datetime) -> "HoiDateTime": + """Create from a Python datetime (microseconds truncated to milliseconds).""" + return cls( + year=dt.year, + month=dt.month, + day=dt.day, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + millisecond=dt.microsecond // 1000, + ) + + @classmethod + def now(cls) -> "HoiDateTime": + """Create from the current local time.""" + return cls.from_datetime(datetime.datetime.now()) + + def to_datetime(self) -> datetime.datetime: + """Convert to a Python datetime.""" + return datetime.datetime( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.millisecond * 1000, + ) + + +@dataclass(frozen=True) +class CalibrationSiteInfo: + """A calibration site from DeckConfiguration.GetCalibrationSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + post: bool + + +@dataclass(frozen=True) +class ChannelHardwareConfigInfo: + """Per-channel hardware config from MLPrepCalibration.GetChannelHardwareConfiguration.""" + + channel: int # ChannelIndex enum value + hardware: int # Hardware type enum value + + +@dataclass(frozen=True) +class ChannelCalibrationValuesInfo: + """Per-channel calibration values from MLPrepCalibration.GetCalibrationValues.""" + + index: int # ChannelIndex enum value + y_offset: float + z_offset: float + squeeze_position: int + z_touchoff: int + pressure_shift: int + pressure_monitoring_shift: int + dispenser_return_distance: float + z_tip_height: float + core_ii: bool + + def to_pretty_string(self) -> str: + """Return a stable one-line representation for logging/reporting.""" + return ( + f"index={self.index}, y_offset={self.y_offset}, z_offset={self.z_offset}, " + f"squeeze_position={self.squeeze_position}, z_touchoff={self.z_touchoff}, " + f"pressure_shift={self.pressure_shift}, " + f"pressure_monitoring_shift={self.pressure_monitoring_shift}, " + f"dispenser_return_distance={self.dispenser_return_distance}, " + f"z_tip_height={self.z_tip_height}, core_ii={self.core_ii}" + ) + + +@dataclass(frozen=True) +class CalibrationValues: + """Full calibration values from MLPrepCalibration.GetCalibrationValues.""" + + independent_offset_x: float + mph_offset_x: float + channel_values: Tuple["ChannelCalibrationValuesInfo", ...] + + def to_pretty_string(self, sort_by_index: bool = True) -> str: + """Return deterministic, human-readable calibration output.""" + channels = self.channel_values + if sort_by_index: + channels = tuple(sorted(channels, key=lambda cv: cv.index)) + + lines = [ + f"Independent offset X: {self.independent_offset_x}", + f"MPH offset X: {self.mph_offset_x}", + "Per-channel calibration values:", + ] + for cv in channels: + lines.append(f" {cv.to_pretty_string()}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.to_pretty_string() + + +@dataclass(frozen=True) +class CalibrationFieldChange: + field: str + old: object + new: object + + +@dataclass(frozen=True) +class ChannelCalibrationDiff: + index: int + state: str # "added" | "removed" | "changed" + changes: Tuple[CalibrationFieldChange, ...] + old: Optional[ChannelCalibrationValuesInfo] + new: Optional[ChannelCalibrationValuesInfo] + + +@dataclass(frozen=True) +class CalibrationValuesDiff: + top_level_changes: Tuple[CalibrationFieldChange, ...] + channel_diffs: Tuple[ChannelCalibrationDiff, ...] + + @property + def has_changes(self) -> bool: + return bool(self.top_level_changes or self.channel_diffs) + + +def _calibration_value_equal(old: object, new: object, float_tol: float) -> bool: + if isinstance(old, float) and isinstance(new, float): + return math.isclose(old, new, rel_tol=0.0, abs_tol=float_tol) + return old == new + + +def diff_calibration_values( + old: CalibrationValues, + new: CalibrationValues, + float_tol: float = 1e-6, +) -> CalibrationValuesDiff: + """Return structured diff between two calibration snapshots.""" + + top_level_changes = [] + for field_name in ("independent_offset_x", "mph_offset_x"): + old_value = getattr(old, field_name) + new_value = getattr(new, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + top_level_changes.append( + CalibrationFieldChange(field=field_name, old=old_value, new=new_value) + ) + + old_channels = {cv.index: cv for cv in old.channel_values} + new_channels = {cv.index: cv for cv in new.channel_values} + channel_diffs = [] + for idx in sorted(set(old_channels) | set(new_channels)): + old_cv = old_channels.get(idx) + new_cv = new_channels.get(idx) + if old_cv is None and new_cv is not None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="added", + changes=(), + old=None, + new=new_cv, + ) + ) + continue + if old_cv is not None and new_cv is None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="removed", + changes=(), + old=old_cv, + new=None, + ) + ) + continue + assert old_cv is not None and new_cv is not None + + field_changes = [] + for f in fields(ChannelCalibrationValuesInfo): + field_name = f.name + old_value = getattr(old_cv, field_name) + new_value = getattr(new_cv, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + field_changes.append(CalibrationFieldChange(field=field_name, old=old_value, new=new_value)) + if field_changes: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="changed", + changes=tuple(field_changes), + old=old_cv, + new=new_cv, + ) + ) + + return CalibrationValuesDiff( + top_level_changes=tuple(top_level_changes), + channel_diffs=tuple(channel_diffs), + ) + + +def format_calibration_diff(diff: CalibrationValuesDiff) -> str: + """Return a concise, human-readable diff summary.""" + if not diff.has_changes: + return "No calibration differences." + + lines = ["Calibration differences:"] + if diff.top_level_changes: + lines.append("Top-level:") + for change in diff.top_level_changes: + lines.append(f" {change.field}: {change.old} -> {change.new}") + + if diff.channel_diffs: + lines.append("Per-channel:") + for channel_diff in diff.channel_diffs: + if channel_diff.state == "added": + assert channel_diff.new is not None + lines.append(f" index={channel_diff.index}: added ({channel_diff.new.to_pretty_string()})") + continue + if channel_diff.state == "removed": + assert channel_diff.old is not None + lines.append( + f" index={channel_diff.index}: removed ({channel_diff.old.to_pretty_string()})" + ) + continue + changed_fields = ", ".join( + f"{change.field}: {change.old} -> {change.new}" for change in channel_diff.changes + ) + lines.append(f" index={channel_diff.index}: {changed_fields}") + + return "\n".join(lines) + + +@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: Aspiration, + 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 + 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, + search_start_position=0.0, + channel_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 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 + 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 + 2.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 +# ============================================================================= + + +# Sentinel meaning "dest not supplied — resolve firmware_path JIT at send time." +# PrepClient.send_command detects this and replaces it with the resolved Address +# before delegating to the base TCP layer. Using a real Address sentinel (rather +# than None) keeps TCPCommand.__init__ happy without any additional branching. +_UNRESOLVED = Address(-1, -1, -1) + + +@dataclass +class PrepCommand(TCPCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with optional ``dest: Address`` (kw-only, + defaulted) plus any ``Annotated`` payload fields. ``build_parameters()`` + is inherited from ``TCPCommand`` and serialises only ``Annotated`` fields + via ``HoiParams.from_struct``, so ``dest`` is automatically excluded from + the wire payload. + + Firmware target is declared via the class-level ``firmware_path`` attribute; + ``PrepClient.send_command`` resolves it JIT. Polymorphic-dest commands (e.g. + ``PrepGetPositions`` on MPH vs pipettor) can set ``firmware_path = None`` + and require callers to pass an explicit ``dest=``. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + # Declared by each concrete subclass. None means "caller must supply dest=". + firmware_path: ClassVar[Optional[str]] = None + + # Aggregates populated by ``__init_subclass__`` at import time (unique paths for chatterbox seeding). + _ALL_PATHS: ClassVar[Set[str]] = set() + + dest: Address = field(default=_UNRESOLVED, kw_only=True) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + path = cls.__dict__.get("firmware_path") + if path is None: + return + PrepCommand._ALL_PATHS.add(path) + + def __post_init__(self): + super().__init__(self.dest) + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map HoiResult entry → 0-indexed channel via the first per-channel struct-array field. + + Prep commands carry a ``StructArray`` of per-channel parameters whose + elements have a ``channel`` attribute (e.g. ``aspirate_parameters[i].channel``). + Entry N maps to the ``channel`` of element N. Commands without such a field + (``PrepGetPositions``, ``PrepIsParked``, …) fall back to the entry index. + """ + for f in fields(self): + value = getattr(self, f.name, None) + if not isinstance(value, list) or not value: + continue + if entry_index >= len(value): + continue + elem = value[entry_index] + channel = getattr(elem, "channel", None) + if channel is None: + continue + try: + return int(channel) + except (TypeError, ValueError): + continue + return entry_index + + +@dataclass +class PrepStatusRequest(PrepCommand): + """Base for Prep commands that use HOI STATUS_REQUEST (``action_code == Hoi2Action.STATUS_REQUEST``). + + Subclasses target various firmware objects (Pipettor, MLPrep, MLPrepService, + DeckConfiguration, calibration, etc.). Responses still use the default + ``response_required=True`` in :meth:`TCPCommand.build`. + """ + + action_code = Hoi2Action.STATUS_REQUEST + + +@dataclass +class PrepProbeRequest(PrepCommand): + """Ad-hoc STATUS_REQUEST with runtime command_id and interface_id. + + Use with :meth:`~PrepClient.send_query` when the target command_id is only + known at runtime. Always supply ``dest=`` explicitly; the JIT firmware-path + resolver is bypassed because ``firmware_path = None``. + + ``command_id`` and ``interface_id`` are dataclass instance fields that shadow + the class-level defaults in :class:`~pylabrobot.hamilton.transport.tcp.commands.TCPCommand`, + so :meth:`TCPCommand.build` picks up the per-instance values correctly. + """ + + action_code = Hoi2Action.STATUS_REQUEST + firmware_path = None + dest: Address + command_id: int + interface_id: int = 3 + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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_position, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: 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 MphMoveToPosition(PrepCommand): + """Move MPH gantry to absolute XYZ on IMph (cmd=17, dest=MphRoot.MPH). + + Wire matches vendor ``MoveToPosition(positionX, positionY, positionZ)`` as three + plain ``f32`` scalars (see mph.yaml) — not :class:`GantryMoveXYZParameters`, which + is Pipettor-only. Use :class:`MphMoveToPosition` / :class:`MphMoveToPositionViaLane` + for MPH motion; :class:`PrepMoveToPosition` targets PipettorRoot only. + """ + + command_id = 17 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MphMoveToPositionViaLane(PrepCommand): + """Move MPH gantry to absolute XYZ via lane (cmd=18, dest=MphRoot.MPH). + + Same payload as :class:`MphMoveToPosition`; vendor ``MoveToPositionViaLane``. + """ + + command_id = 18 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + +@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 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD via MPH coordinator (cmd=1, dest=MphRoot.MPH). + + One AspirateParametersNoLldAndMonitoring struct per active probe — each with + its own explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all + entries. The array length equals the number of active probes (not necessarily 8). + """ + + command_id = 1 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class MphDispenseNoLld(PrepCommand): + """Dispense without LLD via MPH coordinator (cmd=5, dest=MphRoot.MPH). + + One DispenseParametersNoLld struct per active probe — each with its own + explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all entries. + """ + + command_id = 5 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class MphAspirateNoLldMonitoring2(PrepCommand): + """Aspirate V2 with liquid-following via MPH coordinator (cmd=29, dest=MphRoot.MPH). + + Uses ``AspirateParametersNoLldAndMonitoring2`` which includes a + ``ContainerDescription`` frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class MphDispenseNoLld2(PrepCommand): + """Dispense V2 without LLD via MPH coordinator (cmd=33, dest=MphRoot.MPH). + + Uses ``DispenseParametersNoLld2`` which includes a ``ContainerDescription`` + frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 33 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class MphAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD via MPH coordinator (cmd=2, dest=MphRoot.MPH).""" + + command_id = 2 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class MphAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring via MPH coordinator (cmd=3, dest=MphRoot.MPH).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class MphAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM via MPH coordinator (cmd=4, dest=MphRoot.MPH).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class MphDispenseWithLld(PrepCommand): + """Dispense with LLD via MPH coordinator (cmd=6, dest=MphRoot.MPH).""" + + command_id = 6 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class MphAspirateTadm2(PrepCommand): + """Aspirate V2 with TADM, no LLD via MPH coordinator (cmd=30, dest=MphRoot.MPH).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class MphAspirateWithLld2(PrepCommand): + """Aspirate V2 with LLD and monitoring via MPH coordinator (cmd=31, dest=MphRoot.MPH).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class MphAspirateWithLldTadm2(PrepCommand): + """Aspirate V2 with LLD and TADM via MPH coordinator (cmd=32, dest=MphRoot.MPH).""" + + command_id = 32 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class MphDispenseWithLld2(PrepCommand): + """Dispense V2 with LLD via MPH coordinator (cmd=34, dest=MphRoot.MPH).""" + + command_id = 34 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + +# 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move pipettor gantry to position (cmd=26, dest=PipettorRoot only). + + Payload is :class:`GantryMoveXYZParameters` with ``FrontChannel`` / ``RearChannel`` + only in ``axis_parameters``. MPH motion must use :class:`MphMoveToPosition` on + ``MLPrepRoot.MphRoot.MPH``, not this command. + """ + + command_id = 26 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move pipettor gantry via lane (cmd=27, dest=PipettorRoot only). + + Same constraints as :class:`PrepMoveToPosition`. MPH: use + :class:`MphMoveToPositionViaLane`. + """ + + command_id = 27 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepGetPositions(PrepStatusRequest): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + @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).""" + + command_id = 28 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + 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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrep" + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepStatusRequest): + """Query whether MLPrep is initialized. Firmware yaml: [1:2] GetIsInitialized(void) -> value: bool.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + firmware_path = "MLPrepRoot.MLPrep" + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + firmware_path = "MLPrepRoot.MLPrep" + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrep" + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + firmware_path = "MLPrepRoot.MLPrep" + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepStatusRequest): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + firmware_path = "MLPrepRoot.MLPrep" + + @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). + + Reuses :class:`GantryMoveXYZParameters` on the **MLPrep** coordinator, not + PipettorRoot — distinct from :class:`PrepMoveToPosition` and from MPH moves + (:class:`MphMoveToPosition`). + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MLPrep" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MLPrep" + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepIsParked(PrepStatusRequest): + """Query parked status (cmd=34, dest=MLPrep). Firmware yaml: IsParked(void) -> parked: bool.""" + + command_id = 34 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepIsSpread(PrepStatusRequest): + """Query spread status (cmd=35, dest=MLPrep). Same HOI pattern as :class:`PrepIsParked`.""" + + command_id = 35 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +# ----------------------------------------------------------------------------- +# 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 _CalibrationSiteDefinitionWire: + """Wire shape for one CalibrationSiteDefinition (GetCalibrationSiteDefinitions element). + + Same fields as DeckSiteDefinition plus trailing Post (BOOL). + """ + + 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 + post: PaddedBool + + +@dataclass +class _ChannelHardwareConfigWire: + """Wire shape for ChannelHardwareConfig (GetChannelHardwareConfiguration element).""" + + channel: WEnum # ChannelIndex + hardware: WEnum # Hardware type enum (interface 2, id 1) + + +@dataclass +class _ChannelCalibrationValuesWire: + """Wire shape for ChannelCalibrationValues (GetCalibrationValues element).""" + + index: WEnum # ChannelIndex + y_offset: F32 + z_offset: F32 + squeeze_position: U32 + z_touchoff: U32 + pressure_shift: U32 + pressure_monitoring_shift: U32 + dispenser_return_distance: F32 + z_tip_height: F32 + core_ii: PaddedBool + + +@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 +# (inherit :class:`PrepStatusRequest`, defined above) +# ----------------------------------------------------------------------------- + + +@dataclass +class PrepGetIsEnclosurePresent(PrepStatusRequest): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 21 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetSafeSpeedsEnabled(PrepStatusRequest): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 28 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetDefaultTraverseHeight(PrepStatusRequest): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(PrepStatusRequest): + """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 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(PrepStatusRequest): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @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 PrepGetCalibrationSiteDefinitions(PrepStatusRequest): + """GetCalibrationSiteDefinitions (cmd=3, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of CalibrationSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX/Y/Z: F32, Length, Width, Height: F32, Post: BOOL + """ + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_CalibrationSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetDeckSiteDefinitions(PrepStatusRequest): + """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 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(PrepStatusRequest): + """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 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetChannelBounds(PrepStatusRequest): + """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 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor.PipettorService" + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + +@dataclass +class PrepGetPresentChannels(PrepStatusRequest): + """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 + firmware_path = "MLPrepRoot.MLPrepService" + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + +# ----------------------------------------------------------------------------- +# MLPrepCalibration commands +# ----------------------------------------------------------------------------- + + +@dataclass +class PrepBeginCalibration(PrepCommand): + """BeginCalibration (cmd=1, dest=MLPrepCalibration). Enter calibration mode.""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class PrepCancelCalibration(PrepCommand): + """CancelCalibration (cmd=2, dest=MLPrepCalibration). Cancel active calibration session.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class PrepEndCalibration(PrepCommand): + """EndCalibration (cmd=3, dest=MLPrepCalibration). End calibration and store results with timestamp.""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration" + date_time: Annotated[HoiDateTime, Struct()] + + +@dataclass +class PrepResetCalibration(PrepCommand): + """ResetCalibration (cmd=4, dest=MLPrepCalibration). Reset calibration data, optionally storing.""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrepCalibration" + store: PaddedBool + + +@dataclass +class PrepCalibrationInitialize(PrepCommand): + """CalibrationInitialize (cmd=5, dest=MLPrepCalibration). Initialize calibration hardware.""" + + command_id = 5 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class NeedleDefinition: + """Wire shape for NeedleDefinition (MLPrepCalibration local struct, id=2). + + When default_values=True the firmware uses stored defaults for all fields. + TipDefinition is nested (global pool source_id=1, ref_id=8). + """ + + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + tip_definition: Annotated[TipDefinition, Struct()] + tip_mask: U32 + + @classmethod + def defaults(cls) -> "NeedleDefinition": + """Return an all-defaults instance (firmware fills in stored values).""" + return cls( + default_values=True, + x_position=0.0, + y_position=0.0, + z_start=0.0, + z_stop=0.0, + tip_definition=TipDefinition( + default_values=True, + id=0, + volume=0.0, + length=0.0, + tip_type=0, + has_filter=False, + is_needle=False, + is_tool=False, + label="", + ), + tip_mask=0, + ) + + +@dataclass +class PrepSelfCalibrate(PrepCommand): + """SelfCalibrate (cmd=6, dest=MLPrepCalibration). + + Runs a full self-calibration sequence. Set individual booleans to select + which calibration phases to run. Pass NeedleDefinition.defaults() to use + firmware-stored needle parameters. + """ + + command_id = 6 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channels: WEnum # ChannelIndex + axis: PaddedBool + pressure: PaddedBool + touchoff: PaddedBool + needle: Annotated[NeedleDefinition, Struct()] + + +@dataclass +class PrepCalibrateXAxis(PrepCommand): + """CalibrateXAxis (cmd=7, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 7 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateYAxis(PrepCommand): + """CalibrateYAxis (cmd=8, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 8 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateZAxis(PrepCommand): + """CalibrateZAxis (cmd=9, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 9 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateSqueeze(PrepCommand): + """CalibrateSqueeze (cmd=14, dest=MLPrepCalibration). Returns position: U32.""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + position: U32 + + +@dataclass +class PrepCalibrateSqueezeTips(PrepCommand): + """CalibrateSqueezeTips (cmd=15, dest=MLPrepCalibration). + + Takes per-channel TipPositionParameters (same struct as pick_up_tips) and + returns per-channel squeeze positions as a list of u32. + """ + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channels: Annotated[list[TipPositionParameters], StructArray()] + + @dataclass(frozen=True) + class Response: + positions: U32Array + + +@dataclass +class PrepGetCalibrationValues(PrepStatusRequest): + """GetCalibrationValues (cmd=16, dest=MLPrepCalibration). + + Returns independentOffsetX (F32), mphOffsetX (F32), and per-channel + calibration values as a StructArray of ChannelCalibrationValues. + """ + + command_id = 16 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + independent_offset_x: F32 + mph_offset_x: F32 + channel_values: Annotated[list[_ChannelCalibrationValuesWire], StructArray()] + + +@dataclass +class PrepGetChannelHardwareConfiguration(PrepStatusRequest): + """GetChannelHardwareConfiguration (cmd=24, dest=MLPrepCalibration). + + Response is a StructArray of ChannelHardwareConfig: Channel (enum) + Hardware (enum). + """ + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + channels: Annotated[list[_ChannelHardwareConfigWire], StructArray()] diff --git a/pylabrobot/hamilton/prep/standard.py b/pylabrobot/hamilton/prep/standard.py new file mode 100644 index 00000000000..72202342286 --- /dev/null +++ b/pylabrobot/hamilton/prep/standard.py @@ -0,0 +1,248 @@ +"""Operation types for Hamilton Prep liquid handling.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union + +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.resources import Container, Tip, TipRack, TipSpot, Trash, Well + + +@dataclass(frozen=True) +class Mix: + """Mix parameters for aspiration/dispense operations.""" + + volume: float + repetitions: int + flow_rate: float + + +# --------------------------------------------------------------------------- +# Independent channel operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Pickup: + """Pick up a tip from a tip spot.""" + + resource: TipSpot + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class TipDrop: + """Drop a tip to a tip spot or trash.""" + + resource: Union[TipSpot, Trash] + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class Aspiration: + """Aspirate liquid from a container using an independent channel.""" + + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Dispense: + """Dispense liquid to a container using an independent channel.""" + + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +# --------------------------------------------------------------------------- +# 96-head operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PickupTipRack: + """Pick up tips from a tip rack using the 96-head.""" + + resource: TipRack + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class DropTipRack: + """Drop tips to a tip rack or trash using the 96-head.""" + + resource: Union[TipRack, Trash] + offset: Coordinate + + +@dataclass(frozen=True) +class MultiHeadAspirationPlate: + """Aspirate from wells in a plate using the 96-head.""" + + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispensePlate: + """Dispense to wells in a plate using the 96-head.""" + + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadAspirationContainer: + """Aspirate from a single container (trough) using the 96-head.""" + + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispenseContainer: + """Dispense to a single container (trough) using the 96-head.""" + + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +# --------------------------------------------------------------------------- +# 8-head operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Head8TipPickup: + """Pick up tips with the 8MPH head. + + ``tip_spots[i]`` is the tip spot for active channel ``use_channels[i]``. + """ + + tip_spots: List[TipSpot] + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class Head8TipDrop: + """Drop tips with the 8MPH head. + + ``resources[i]`` is the destination (TipSpot or Trash) for active channel ``use_channels[i]``. + ``tips[i]`` carries the tip geometry so the backend can compute drop heights. + """ + + resources: List[Union[TipSpot, Trash]] + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class Head8AspirationWells: + """Aspirate from an explicit list of wells using the 8MPH head. + + ``wells[i]`` is the well for active channel ``use_channels[i]``. + Duplicate well entries are valid (e.g. 2 probes in one 24-well well). + """ + + wells: List[Well] + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Head8DispenseWells: + """Dispense to an explicit list of wells using the 8MPH head. + + ``wells[i]`` is the well for active channel ``use_channels[i]``. + """ + + wells: List[Well] + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Head8AspirationContainer: + """Aspirate from a single container (trough) using the 8MPH head.""" + + container: Container + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Head8DispenseContainer: + """Dispense to a single container (trough) using the 8MPH head.""" + + container: Container + use_channels: Tuple[int, ...] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] diff --git a/pylabrobot/hamilton/prep/tests/__init__.py b/pylabrobot/hamilton/prep/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/prep/tests/channels_tests.py b/pylabrobot/hamilton/prep/tests/channels_tests.py new file mode 100644 index 00000000000..65f994e1211 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/channels_tests.py @@ -0,0 +1,46 @@ +"""PrepPIPChannel facade + enumeration against the chatterbox.""" + +from __future__ import annotations + +import asyncio + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep.channels import PrepChannels, PrepPIPChannel +from pylabrobot.resources.hamilton import STARLetDeck + + +def _run(coro): + asyncio.run(coro) + + +def test_channels_match_info_num_channels(): + """PrepChannels.channels length matches info.config.num_channels on a default chatterbox.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert len(p.channels.channels) == p.info.config.num_channels + for i, ch in enumerate(p.channels.channels): + assert isinstance(ch, PrepPIPChannel) + assert ch.index == i + await p.stop() + + _run(_t()) + + +def test_channels_attach_bounds_even_when_empty_offline(): + """Chatterbox firmware tree is empty, so bounds are None — but the attribute must exist.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + for ch in p.channels.channels: + assert hasattr(ch, "bounds") + assert ch.bounds is None + await p.stop() + + _run(_t()) diff --git a/pylabrobot/hamilton/prep/tests/client_tests.py b/pylabrobot/hamilton/prep/tests/client_tests.py new file mode 100644 index 00000000000..5e51745bc32 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/client_tests.py @@ -0,0 +1,177 @@ +import asyncio + +import pytest + +from pylabrobot.hamilton.prep import Prep, PrepChatterboxClient +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import PrepChannels +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.resources.hamilton import STARLetDeck + + +def test_chatterbox_sets_resolved_interfaces_and_channels(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + assert isinstance(p.client.mlprep_address, Address) + addr = await p.client.resolve_path("MLPrepRoot.PipettorRoot.Pipettor") + assert isinstance(addr, Address) + assert p.info.config.num_channels == 2 + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.num_channels == 2 + assert p.channels.setup_finished is True + # Default setup: use_v1_aspirate_dispense=False → v2 probe passes (chatterbox stubs). + assert p.channels._supports_v2_pipetting is True + + await p.stop() + assert p.info._config is None + + asyncio.run(_run()) + + +def test_chatterbox_use_v1_skips_v2_probe(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.setup_finished is True + assert p.channels._supports_v2_pipetting is False + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_motion_method_and_power_commands(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + await p.park() + await p.spread() + assert p.method is not None + await p.method.begin(automatic_pause=False) + await p.method.end() + await p.cancel_power_down() + await p.stop() + + asyncio.run(_run()) + + +def test_prep_method_run_context_manager_aborts_on_exception(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.method is not None + + calls: list[str] = [] + orig_begin = p.method.begin + orig_end = p.method.end + orig_abort = p.method.abort + + async def rec_begin(automatic_pause: bool = False) -> None: + calls.append("begin") + await orig_begin(automatic_pause=automatic_pause) + + async def rec_end() -> None: + calls.append("end") + await orig_end() + + async def rec_abort() -> None: + calls.append("abort") + await orig_abort() + + p.method.begin = rec_begin # type: ignore[method-assign] + p.method.end = rec_end # type: ignore[method-assign] + p.method.abort = rec_abort # type: ignore[method-assign] + + # Clean exit: begin + end, no abort. + async with p.method.run(): + pass + assert calls == ["begin", "end"] + + # Exception inside: begin + abort, re-raised. + calls.clear() + with pytest.raises(RuntimeError, match="boom"): + async with p.method.run(): + raise RuntimeError("boom") + assert calls == ["begin", "abort"] + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_wires_calibration_after_setup(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.info.num_channels == p.info.config.num_channels + assert p.info.has_mph == p.info.config.has_mph + assert p.calibration is not None + assert p.calibration.num_channels == p.info.config.num_channels + assert p.calibration.has_mph == p.info.config.has_mph + assert isinstance(p.gripper, PrepGripper) + async with p.core_grippers() as arm: + assert isinstance(arm, PrepGripperArm) + assert isinstance(arm.backend, PrepGripper) + await p.stop() + + asyncio.run(_run()) + + +def test_send_command_surfaces_clear_error_for_unresolvable_path(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + missing = "MLPrepRoot.MLPrep" + orig_resolve = p.client.resolve_path + + async def _fake_resolve(path: str): + if path == missing: + raise KeyError(path) + return await orig_resolve(path) + + p.client.resolve_path = _fake_resolve # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="firmware path"): + await p.client.send_command(PrepCmd.PrepPark()) + await p.stop() + + asyncio.run(_run()) + + +def test_chatterbox_preregisters_diagnostic_paths(): + async def _run() -> None: + d = PrepChatterboxClient() + await d.setup() + assert isinstance(await d.resolve_path("MLPrepRoot.MLPrepCpu"), Address) + assert isinstance(await d.resolve_path("MLPrepRoot.PipettorRoot.ModuleInformation"), Address) + await d.stop() + + asyncio.run(_run()) + + +def test_force_initialize_skips_is_initialized_check(): + """When force_initialize=True, Prep.setup() never queries is_initialized.""" + from unittest.mock import AsyncMock + + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + p.info.is_initialized = AsyncMock(side_effect=AssertionError("should not be called")) # type: ignore[method-assign] + await p.setup(force_initialize=True) + p.info.is_initialized.assert_not_called() + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py new file mode 100644 index 00000000000..938136983bd --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -0,0 +1,645 @@ +"""Tests for PrepHead8. + +Covers core logic that must survive refactors: + - _resolve_probe_positions: pitch validation for 96-well columns and interleaved 384-well + - _validate_container_span: minimum Y-span check for trough path + - all-8-channel enforcement (ganged head constraint) + - V1/V2 aspirate/dispense dispatch and LLD/TADM kwargs +""" + +from __future__ import annotations + +import asyncio +from typing import Any, List, Sequence, Tuple +from unittest.mock import MagicMock + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import ( + LLDMode, + _build_pipettor_gantry_move_parameters, +) +from pylabrobot.hamilton.prep.head8 import PROBE_PITCH_MM, PrepHead8 +from pylabrobot.hamilton.prep.standard import ( + Head8AspirationWells, + Head8DispenseWells, + Head8TipDrop, + Head8TipPickup, +) +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import Cor_Axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR +from pylabrobot.resources.tip_rack import TipSpot + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ALL8: Tuple[int, ...] = (0, 1, 2, 3, 4, 5, 6, 7) + + +def _make_deck(): + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + src_plate = deck[0] = Cor_Axy_96_wellplate_500uL_Ub("src") + dst_plate = deck[4] = Cor_Axy_96_wellplate_500uL_Ub("dst") + return deck, tip_rack, src_plate, dst_plate + + +def _tips_from_spots(tip_spots: Sequence[TipSpot]) -> List[Any]: + return [s.get_tip() for s in tip_spots] + + +def _pickup_op( + tip_spots: Sequence[TipSpot], + use_channels: Tuple[int, ...] = _ALL8, +) -> Head8TipPickup: + return Head8TipPickup( + tip_spots=list(tip_spots), + use_channels=use_channels, + offset=Coordinate.zero(), + tips=_tips_from_spots(tip_spots), + ) + + +def _drop_op( + resources: Sequence[TipSpot], + tips: Sequence[Any], + use_channels: Tuple[int, ...] = _ALL8, +) -> Head8TipDrop: + return Head8TipDrop( + resources=list(resources), + use_channels=use_channels, + offset=Coordinate.zero(), + tips=tips, + ) + + +def _asp_wells_op( + wells: Sequence[Any], + tips: Sequence[Any], + volume: float, + use_channels: Tuple[int, ...] = _ALL8, +) -> Head8AspirationWells: + return Head8AspirationWells( + wells=list(wells), + use_channels=use_channels, + offset=Coordinate.zero(), + tips=tips, + volume=volume, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + +def _disp_wells_op( + wells: Sequence[Any], + tips: Sequence[Any], + volume: float, + use_channels: Tuple[int, ...] = _ALL8, +) -> Head8DispenseWells: + return Head8DispenseWells( + wells=list(wells), + use_channels=use_channels, + offset=Coordinate.zero(), + tips=tips, + volume=volume, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + +def _make_head8() -> PrepHead8: + return PrepHead8(client=None, info=None) # type: ignore[arg-type] + + +def _record_send(prep: Prep) -> tuple[list[Any], Any]: + captured: list[Any] = [] + orig_send = prep.client.send_command + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.send_command = recording # type: ignore[method-assign, assignment] + return captured, orig_send + + +# --------------------------------------------------------------------------- +# Group 1: _resolve_probe_positions / _validate_container_span +# --------------------------------------------------------------------------- + + +def test_resolve_probe_positions_valid_96well_column(): + """96-well column A→H has exactly 9mm pitch — should pass and return expected Ys.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + wells = plate.column(0) + + be = _make_head8() + ys = be._resolve_probe_positions(wells) + + assert len(ys) == 8 + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + for i, y in enumerate(ys): + assert y == pytest.approx(ref_y - i * PROBE_PITCH_MM), ( + f"probe {i}: expected {ref_y - i * PROBE_PITCH_MM}, got {y}" + ) + + +def test_resolve_probe_positions_misaligned_raises(): + """Wells not at 9mm pitch must raise ValueError with a descriptive message.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + # Swap rows 0 and 1 — now the pitch from well[0] to well[1] is wrong. + bad_wells = [col[1], col[0]] + list(col[2:]) + + be = _make_head8() + with pytest.raises(ValueError, match="9.0 mm probe pitch"): + be._resolve_probe_positions(bad_wells) + + +def test_resolve_probe_positions_interleaved_384well(): + """Every-other-row selection on a 96-well plate (simulating 4.5mm × 2 = 9mm pitch) passes.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + be = _make_head8() + ys = be._resolve_probe_positions(col) + ref_y = col[0].get_absolute_location("c", "c", "cavity_bottom").y + assert ys[0] == pytest.approx(ref_y) + assert ys[7] == pytest.approx(ref_y - 7 * PROBE_PITCH_MM) + + +def test_validate_container_span_sufficient(): + """Container wider than 63mm passes without error.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + # Cor_Axy_96 is 85.48mm in Y — well above 63mm minimum. + be = _make_head8() + be._validate_container_span(plate) # should not raise + + +def test_validate_container_span_too_narrow(): + """Container narrower than 63mm raises ValueError.""" + narrow = MagicMock() + narrow.name = "narrow_container" + narrow.get_size_y.return_value = 40.0 # less than 63mm + + be = _make_head8() + with pytest.raises(ValueError, match="too narrow"): + be._validate_container_span(narrow) + + +# --------------------------------------------------------------------------- +# Group 2: all-8-channel enforcement + PrepHead8 wiring +# --------------------------------------------------------------------------- + + +def test_partial_channel_pickup_raises_value_error(): + """PrepHead8 rejects pick_up_tips8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(1)[4:] # E2, F2, G2, H2 + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.pick_up_tips8(_pickup_op(spots, use_channels=(4, 5, 6, 7))) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_present_after_chatterbox_setup(): + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + assert isinstance(p.head8, PrepHead8) + await p.stop() + + asyncio.run(_run()) + + +def test_head8_full_flow(): + """pick_up_tips8 → aspirate8 → dispense8 → drop_tips8 on chatterbox.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=20)) + await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=20)) + await p.head8.drop_tips8(_drop_op(spots, tips)) + + await p.stop() + + asyncio.run(_run()) + + +def test_mph_move_to_position_command_metadata(): + move = PrepCmd.MphMoveToPosition(x_position=1.5, y_position=2.5, z_position=120.0) + assert move.firmware_path == "MLPrepRoot.MphRoot.MPH" + assert move.command_id == 17 + assert move.x_position == 1.5 and move.y_position == 2.5 and move.z_position == 120.0 + + via = PrepCmd.MphMoveToPositionViaLane(x_position=0.0, y_position=0.0, z_position=0.0) + assert via.command_id == 18 + assert via.firmware_path == move.firmware_path + params = move.build_parameters() + assert params is not None + + +def test_build_pipettor_gantry_move_parameters_maps_rear_front(): + m = _build_pipettor_gantry_move_parameters(10.0, [0, 1], [20.0, 30.0], [40.0, 50.0]) + assert m.gantry_x_position == 10.0 + assert len(m.axis_parameters) == 2 + assert m.axis_parameters[0].channel == PrepCmd.ChannelIndex.RearChannel + assert m.axis_parameters[0].y_position == 20.0 + assert m.axis_parameters[0].z_position == 40.0 + assert m.axis_parameters[1].channel == PrepCmd.ChannelIndex.FrontChannel + assert m.axis_parameters[1].y_position == 30.0 + assert m.axis_parameters[1].z_position == 50.0 + + +def test_head8_move_to_position_sends_mph_wire_commands(): + """PrepHead8.move_to_position sends MphMoveToPosition / ViaLane.""" + + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.move_to_position(11.0, 22.5, 99.0) + direct = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + assert len(direct) == 1 + assert direct[0].x_position == 11.0 + assert direct[0].y_position == 22.5 + assert direct[0].z_position == 99.0 + + await p.head8.move_to_position(1.0, 2.0, 3.0, via_lane=True) + lanes = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPositionViaLane)] + assert len(lanes) == 1 + assert lanes[0].x_position == 1.0 and lanes[0].y_position == 2.0 and lanes[0].z_position == 3.0 + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_default_pre_position_sends_mph_move_then_pickup(): + """Default pre_position=True issues MphMoveToPosition before MphPickupTips.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(_pickup_op(tip_rack.column(0))) + + mph_seq = [ + c for c in captured if isinstance(c, (PrepCmd.MphMoveToPosition, PrepCmd.MphPickupTips)) + ] + assert len(mph_seq) >= 2 + assert isinstance(mph_seq[0], PrepCmd.MphMoveToPosition) + assert isinstance(mph_seq[1], PrepCmd.MphPickupTips) + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_pre_position_false_skips_mph_move(): + """Explicit pre_position=False sends only MphPickupTips among MPH move/pickup pair.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(_pickup_op(tip_rack.column(1)), pre_position=False) + + mph_moves = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.MphPickupTips)] + assert mph_moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_partial_channel_aspirate_raises_value_error(): + """PrepHead8 rejects aspirate8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.aspirate8( + _asp_wells_op( + src_plate.column(0)[:4], + tips[:4], + volume=10, + use_channels=(0, 1, 2, 3), + ) + ) + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 3: V2 aspirate/dispense dispatch +# --------------------------------------------------------------------------- + + +def test_head8_v2_aspirate_sends_mphaspiratenolldmonitoring2(): + """Chatterbox default (use_v1=False) → V2 command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + + asp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + assert len(asp_cmds) == 1, f"Expected 1 MphAspirateNoLldMonitoring2, got {len(asp_cmds)}" + assert len(v1_cmds) == 0, "V1 aspirate command should not be sent when V2 is supported" + assert len(asp_cmds[0].aspirate_parameters) == 1, ( + "MPH sends a single struct element (probe-0 reference); firmware drives all 8 probes" + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v2_dispense_sends_mphdispensetnolld2(): + """Chatterbox default (use_v1=False) → V2 dispense command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=10)) + + disp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + assert len(disp_cmds) == 1, f"Expected 1 MphDispenseNoLld2, got {len(disp_cmds)}" + assert len(v1_cmds) == 0, "V1 dispense command should not be sent when V2 is supported" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v1_fallback_when_use_v1_flag_set(): + """use_v1_aspirate_dispense=True → V1 command classes are sent for MPH too.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=10)) + + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_asp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + v1_disp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + + assert len(v2_asp) == 0, "V2 aspirate should not be sent with use_v1=True" + assert len(v2_disp) == 0, "V2 dispense should not be sent with use_v1=True" + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate, got {len(v1_asp)}" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense, got {len(v1_disp)}" + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 4: LLD and TADM dispatch +# --------------------------------------------------------------------------- + + +def test_head8_aspirate_tadm_sends_mphaspirate_tadm2(): + """tadm= kwargs → MphAspirateTadm2 (v2, no LLD).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8( + _asp_wells_op(src_plate.column(0), tips, volume=10), + tadm=PrepCmd.TadmParameters.default(), + ) + + tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateTadm2)] + assert len(tadm_cmds) == 1, f"Expected 1 MphAspirateTadm2, got {len(tadm_cmds)}" + no_lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + assert len(no_lld_cmds) == 0, "NoLldMonitoring2 should not be sent when tadm= is set" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_clld_sends_mphaspirate_with_lld2(): + """lld_mode=CAPACITIVE → MphAspirateWithLld2 (v2, LLD, no TADM).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8( + _asp_wells_op(src_plate.column(0), tips, volume=10), + lld_mode=LLDMode.CAPACITIVE, + ) + + lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLld2)] + assert len(lld_cmds) == 1, f"Expected 1 MphAspirateWithLld2, got {len(lld_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_lld_and_tadm_sends_mphaspirate_with_lld_tadm2(): + """lld_mode=CAPACITIVE + tadm= → MphAspirateWithLldTadm2.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8( + _asp_wells_op(src_plate.column(0), tips, volume=10), + lld_mode=LLDMode.CAPACITIVE, + tadm=PrepCmd.TadmParameters.default(), + ) + + lld_tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLldTadm2)] + assert len(lld_tadm_cmds) == 1, f"Expected 1 MphAspirateWithLldTadm2, got {len(lld_tadm_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_dispense_lld_pressure_raises(): + """lld_mode=PRESSURE on dispense raises ValueError — pressure LLD needs aspiration.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + + with pytest.raises(ValueError, match="PRESSURE"): + await p.head8.dispense8( + _disp_wells_op(dst_plate.column(0), tips, volume=10), + lld_mode=LLDMode.PRESSURE, + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_command_version_override_v1(): + """command_version='v1' per-call override forces v1 even when v2 is available.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + tips = _tips_from_spots(spots) + await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.aspirate8( + _asp_wells_op(src_plate.column(0), tips, volume=10), + command_version="v1", + ) + await p.head8.dispense8( + _disp_wells_op(dst_plate.column(0), tips, volume=10), + command_version="v1", + ) + + v1_asp = [c for c in captured if type(c) is PrepCmd.MphAspirateNoLldMonitoring] + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_disp = [c for c in captured if type(c) is PrepCmd.MphDispenseNoLld] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate with override, got {len(v1_asp)}" + assert len(v2_asp) == 0, "V2 aspirate must not be sent with command_version='v1'" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense with override, got {len(v1_disp)}" + assert len(v2_disp) == 0, "V2 dispense must not be sent with command_version='v1'" + + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/tests/__init__.py b/pylabrobot/hamilton/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py new file mode 100644 index 00000000000..e11298f3c77 --- /dev/null +++ b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py @@ -0,0 +1,109 @@ +"""Tests for :mod:`liquid_class_resolver`.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid + + +def _hlc(**overrides: float) -> HamiltonLiquidClass: + base = dict( + curve={0.0: 0.0, 1000.0: 1000.0}, + aspiration_flow_rate=1.0, + aspiration_mix_flow_rate=2.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=4.0, + aspiration_swap_speed=5.0, + aspiration_settling_time=6.0, + aspiration_over_aspirate_volume=7.0, + aspiration_clot_retract_height=8.0, + dispense_flow_rate=9.0, + dispense_mode=0.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=11.0, + dispense_blow_out_volume=12.0, + dispense_swap_speed=13.0, + dispense_settling_time=14.0, + dispense_stop_flow_rate=15.0, + dispense_stop_back_volume=16.0, + ) + base.update(overrides) + return HamiltonLiquidClass(**base) + + +def test_resolve_explicit_returns_copy(): + h = _hlc() + out = resolve_hamilton_liquid_classes([h], [], jet=False, blow_out=False) + assert out == [h] + out[0] = None # type: ignore[assignment] + assert h is not None + + +def test_resolve_auto_non_hamilton_tip_is_none(): + op = SimpleNamespace(tip=object()) + assert resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False) == [None] + + +def test_resolve_auto_hamilton_tip_matches_get_star(): + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + a = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False)[0] + b = get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + assert a is not None and b is not None + assert a.aspiration_flow_rate == b.aspiration_flow_rate + + +def test_resolve_custom_lookup(): + custom = _hlc(aspiration_flow_rate=99.0) + + def lookup(**kwargs): # noqa: ARG001 + return custom + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + got = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False, lookup=lookup)[0] + assert got is not None + assert got.aspiration_flow_rate == 99.0 + + +def test_corrected_volumes_respects_disable_and_none_hlc(): + ops = [SimpleNamespace(volume=100.0)] + hlc = _hlc(curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}) + assert corrected_volumes_for_ops(ops, [hlc], None) == [200.0] + assert corrected_volumes_for_ops(ops, [hlc], [True]) == [100.0] + assert corrected_volumes_for_ops(ops, [None], None) == [100.0] + + +def test_corrected_volumes_length_mismatch_raises(): + with pytest.raises(ValueError, match="hlcs length"): + corrected_volumes_for_ops([SimpleNamespace(volume=1.0)], []) diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 8bcc28e3f86..1fc362db6ef 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,12 +1,15 @@ from .hamilton_decks import ( + HamiltonCoreGrippers, HamiltonDeck, HamiltonSTARDeck, + PrepDeck, STARDeck, STARLetDeck, + prep_core_gripper_mount, ) from .mfx_carriers import * from .mfx_modules import * -from .nimbus_decks import NimbusDeck +from .nimbus_decks import NimbusDeck, nimbus_core_gripper_1000ul_at_waste from .plate_adapters import * from .plate_carriers import * from .tip_carriers import * diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 8c0b9d3fff9..0fef5d50efa 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -2,13 +2,16 @@ 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 from pylabrobot.resources.deck import Deck from pylabrobot.resources.errors import NoLocationError -from pylabrobot.resources.hamilton.tip_creators import hamilton_teaching_needle_300uL +from pylabrobot.resources.hamilton.tip_creators import ( + hamilton_teaching_needle_300uL, + hamilton_tip_300uL_filter, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash @@ -389,6 +392,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 @@ -618,3 +639,85 @@ def STARDeck( with_teaching_rack=with_teaching_rack, core_grippers=core_grippers, ) + + +class PrepDeck(Deck): + """Hamilton PREP deck: labware spots, trash, teaching tip site, and waste positions. + + Geometry aligns with the prep_tcp / MLPrep DeckConfiguration teaching site and waste + sites used by :class:`~pylabrobot.hamilton.prep.channels.PrepChannels` + (``waste_rear``, ``waste_front``, ``waste_mph``). Validate coordinates on hardware + (plastic mounts, calibration) before production use. + + This is **not** a :class:`HamiltonSTARDeck` (rails/teaching rack layout differ). + """ + + def __init__( + self, + name: str = "deck", + size_x: float = 300.0, + size_y: float = 394.0, + size_z: float = 0, + origin: Coordinate = Coordinate.zero(), + category: str = "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)) + spots_list: List[ResourceHolder] = [] + for column in range(2): + for row in range(4): + x = column * 140 + 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, 1.5, 3.75 + ), # Adjusted for plastic corner mounts; validate on hardware + ) + 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=13, size_y=132.7, size_z=73) + self.assign_child_resource(trash, location=Coordinate(280.3, -3, 0)) + + 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), + ) + + 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=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=68.4), + ) + + def __getitem__(self, key: int) -> ResourceHolder: + """Labware spot by index 0–7 (column-major: ``spot_0_0`` … ``spot_1_3``).""" + return self.spots[key] + + def __setitem__(self, key: int, value: Resource): + """Assign a resource to labware spot ``key`` (0–7).""" + self.spots[key].assign_child_resource(value) diff --git a/pylabrobot/resources/hamilton/nimbus_decks.py b/pylabrobot/resources/hamilton/nimbus_decks.py index 00b90c2cbac..7221a2357ee 100644 --- a/pylabrobot/resources/hamilton/nimbus_decks.py +++ b/pylabrobot/resources/hamilton/nimbus_decks.py @@ -12,7 +12,10 @@ from typing import Any, Dict, List, Literal, Optional from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.hamilton.hamilton_decks import HamiltonDeck +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonCoreGrippers, + HamiltonDeck, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.trash import Trash from pylabrobot.serializer import serialize @@ -20,6 +23,25 @@ logger = logging.getLogger(__name__) +def nimbus_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: + """CORE gripper rack for Nimbus decks, co-located with the waste block. + + Derived from measured Hamilton coordinates on the default Nimbus8 deck: + Front paddle (ch_last): Ham(557.352, -293.030, 147.559) → PLR y = 70.800 + Back paddle (ch1): Ham(557.352, -263.820, 147.559) → PLR y = 100.010 + Resource center placed at PLR(708.862, 85.405, 147.559). + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=14.605, + front_channel_y_center=-14.605, + size_x=20.0, + size_y=30.0, + size_z=25.0, + model="nimbus_core_gripper_1000ul_at_waste", + ) + + class NimbusDeck(HamiltonDeck): """Hamilton Nimbus deck. @@ -45,6 +67,7 @@ def __init__( category: str = "deck", origin: Coordinate = Coordinate.zero(), waste_type: Optional[Literal["default_long"]] = "default_long", + core_grippers: Optional[Literal["1000uL-at-waste"]] = "1000uL-at-waste", ) -> None: """Create a new Nimbus deck. @@ -68,6 +91,10 @@ def __init__( origin: PyLabRobot origin coordinate (default: Coordinate.zero()) waste_type: Waste configuration type (default: "default_long"). If "default_long", creates a waste block with 8 channel positions. If None, no waste is created. + core_grippers: CORE gripper rack type (default: "1000uL-at-waste"). If + "1000uL-at-waste", assigns the gripper rack resource at the waste block + using the standard Nimbus8 paddle positions. Requires waste_type="default_long". + If None, no gripper resource is created. """ super().__init__( num_rails=num_rails, @@ -94,10 +121,13 @@ def __init__( # Store waste type for waste position lookup self.waste_type = waste_type + self.core_grippers_type = core_grippers # Create waste resources if specified if waste_type == "default_long": self._create_default_long_waste() + if core_grippers == "1000uL-at-waste": + self._create_core_grippers() def _create_default_long_waste(self) -> None: """Create default_long waste block with 8 channel positions. @@ -164,6 +194,22 @@ def _create_default_long_waste(self) -> None: # Assign waste position to waste block waste_block.assign_child_resource(waste_position, location=pos_plr_rel) + def _create_core_grippers(self) -> None: + """Assign CORE gripper rack to the waste block at the standard Nimbus8 paddle position.""" + waste_block = self.get_resource("default_long_block") + waste_loc = waste_block.get_location_wrt(self) + + # Center of the two paddles in Hamilton coordinates, converted to PLR + center_ham = Coordinate(x=557.352, y=(-293.030 + -263.820) / 2, z=147.559) + center_plr = self.from_hamilton_coordinate(center_ham) + + rel = Coordinate( + x=center_plr.x - waste_loc.x, + y=center_plr.y - waste_loc.y, + z=center_plr.z - waste_loc.z, + ) + waste_block.assign_child_resource(nimbus_core_gripper_1000ul_at_waste(), location=rel) + def rails_to_location(self, rails: int) -> Coordinate: """Convert a rail identifier to an absolute (x, y, z) coordinate. @@ -289,6 +335,7 @@ def serialize(self) -> dict: "rail_width": self._rail_width, "rail_y": self._rail_y, "waste_type": self.waste_type, + "core_grippers": None, # encoded as child resource; prevent double-creation on deserialize } @classmethod @@ -308,15 +355,16 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> "NimbusDeck": """ data_copy = data.copy() original_waste_type = data_copy.get("waste_type") - # Set waste_type=None to prevent __init__() from creating waste block - # The waste block will come from children data (already serialized) + original_core_grippers = data_copy.get("core_grippers") + # Suppress creation of waste/gripper resources in __init__; children carry the serialized data data_copy["waste_type"] = None + data_copy["core_grippers"] = None - # Call parent deserialize (waste block won't be created in __init__) deck = super().deserialize(data_copy, allow_marshal=allow_marshal) - # Restore waste_type attribute from serialized data to keep instance consistent + # Restore type attributes so the instance stays consistent with what was serialized deck.waste_type = original_waste_type + deck.core_grippers_type = original_core_grippers return deck @@ -338,6 +386,7 @@ def from_files( rail_width: Optional[float] = None, rail_y: Optional[float] = None, waste_type: Optional[Literal["default_long"]] = None, + core_grippers: Optional[Literal["1000uL-at-waste"]] = None, ) -> NimbusDeck: """Create a Nimbus deck by parsing config files. @@ -630,4 +679,5 @@ def extract_dck_exsite_ids(layout_num: int) -> List[str]: rail_y=rail_y_val, origin=origin, waste_type=waste_type, + core_grippers=core_grippers, ) diff --git a/pylabrobot/resources/hamilton/tip_carriers.py b/pylabrobot/resources/hamilton/tip_carriers.py index fc06281825f..a19ea670c7b 100644 --- a/pylabrobot/resources/hamilton/tip_carriers.py +++ b/pylabrobot/resources/hamilton/tip_carriers.py @@ -352,3 +352,20 @@ def TIP_CAR_NTR_A00(name: str) -> TipCarrier: ), model="TIP_CAR_NTR_A00", ) + + +def hamilton_prep_ftr_pedestal(name: str) -> TipCarrier: + """Hamilton cat. no.: 6600553-01 + Pedestal for elevating fixed tip racks (FTR) on the MicroLab Prep. + Body: 133.11 x 89.96 x 53.37 mm. FTR rack seats on top of the pedestal. + """ + site = ResourceHolder(name=f"{name}-0", size_x=122.4, size_y=82.6, size_z=0) + site.location = Coordinate(1.5, 1, 53.37) + return TipCarrier( + name=name, + size_x=133.11, + size_y=89.96, + size_z=53.37, + sites={0: site}, + model="hamilton_prep_ftr_pedestal", + ) From 5f8bc6408fd1e9c4da7e837833a73c0f29b1e68c Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:04:56 -0700 Subject: [PATCH 04/13] feat(hamilton.prep): add PrepGripperArm drop_resource and coordinate pick Expose resource-aware plate drop with deck reassignment, arm-level pick_up_at_location, and optional resource_width on pick_up_resource so notebooks no longer compute grip geometry by hand. --- pylabrobot/hamilton/prep/gripper.py | 108 +++++++++- .../hamilton/prep/tests/gripper_tests.py | 187 ++++++++++++++++++ 2 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 pylabrobot/hamilton/prep/tests/gripper_tests.py diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py index 730f6811da0..8a61946264f 100644 --- a/pylabrobot/hamilton/prep/gripper.py +++ b/pylabrobot/hamilton/prep/gripper.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal, Optional from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.resource_holder import ResourceHolder from . import prep_commands as PrepCmd @@ -179,12 +180,14 @@ async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: class PrepGripperArm: - """Thin helper that auto-populates Prep firmware geometry from the target resource. + """Resource-aware helper over :class:`PrepGripper` pose commands. - When ``pick_up_resource()`` is called, resource dimensions (length, height) and the - plate-top Z offset are extracted from the :class:`Resource` automatically. Users - only need to pass firmware tuning knobs (``clearance_y``, ``grip_speed_y``, - ``squeeze_mm``). + Resource path: ``pick_up_resource`` / ``drop_resource`` resolve geometry from the + resource tree (with optional ``offset``) and reassign the held resource on drop. + + Coordinate path: ``pick_up_at_location`` / ``drop_at_location`` take explicit deck + coordinates (escape hatch for taught points). Prep has no grip-force field; + squeeze is controlled via ``clearance_y``, ``squeeze_mm``, and ``grip_speed_y``. """ def __init__( @@ -232,17 +235,39 @@ def _pickup_location( loc = center + offset return Coordinate(loc.x, loc.y, loc.z + pickup_distance_from_bottom) + def _drop_location(self, destination: Resource, offset: Coordinate) -> Coordinate: + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + pdfb = self._pickup_distance_from_bottom + if isinstance(destination, ResourceHolder): + child = destination.get_default_child_location(held) + else: + child = Coordinate.zero() + center = held.center().rotated(held.get_absolute_rotation()) + plate_lfb = destination.get_location_wrt(self._reference_resource, "l", "f", "b") + child + loc = plate_lfb + center + offset + return Coordinate(loc.x, loc.y, loc.z + pdfb) + def _resource_width(self, resource: Resource) -> float: if self._grip_axis == "y": return resource.get_absolute_size_y() return resource.get_absolute_size_x() + def _clear_held_state(self) -> None: + self._holding_resource_width = None + self._pickup_distance_from_bottom = None + self._held_resource = None + async def pick_up_resource( self, resource: Resource, offset: Coordinate = Coordinate.zero(), pickup_distance_from_bottom: Optional[float] = None, *, + resource_width: Optional[float] = None, resource_length: Optional[float] = None, resource_height: Optional[float] = None, plate_top_z_offset: Optional[float] = None, @@ -251,6 +276,8 @@ async def pick_up_resource( squeeze_mm: float = 2.0, ) -> None: pdfb = self._resolve_pickup_distance(resource, pickup_distance_from_bottom) + if resource_width is None: + resource_width = self._resource_width(resource) if resource_length is None: resource_length = resource.get_absolute_size_x() if resource_height is None: @@ -259,7 +286,6 @@ async def pick_up_resource( plate_top_z_offset = resource.get_absolute_size_z() - pdfb location = self._pickup_location(resource, offset, pdfb) - resource_width = self._resource_width(resource) await self.backend.pick_up_at_location( location, resource_width, @@ -274,6 +300,72 @@ async def pick_up_resource( self._holding_resource_width = resource_width self._held_resource = resource + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up at an explicit grip-point coordinate (no resource-tree geometry). + + Sets held width so ``drop_at_location`` works. Does not set a held + :class:`Resource`; use ``drop_resource`` only after ``pick_up_resource``. + """ + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._holding_resource_width = resource_width + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def drop_resource( + self, + destination: Resource, + offset: Coordinate = Coordinate.zero(), + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop the held resource onto a destination resource (e.g. a PrepDeck spot). + + Resolves place geometry from the destination holder + held plate, then + reassigns the resource tree after a successful firmware drop. + """ + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + destination.check_can_drop_resource_here(held) + location = self._drop_location(destination, offset) + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._clear_held_state() + held.unassign() + if isinstance(destination, ResourceHolder): + destination.assign_child_resource(held) + else: + destination.assign_child_resource(held, location=Coordinate.zero()) + async def drop_at_location( self, location: Coordinate, @@ -289,9 +381,7 @@ async def drop_at_location( clearance_y=clearance_y, acceleration_scale_x=acceleration_scale_x, ) - self._holding_resource_width = None - self._pickup_distance_from_bottom = None - self._held_resource = None + self._clear_held_state() async def move_to_location( self, diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py new file mode 100644 index 00000000000..3c310e8b63e --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -0,0 +1,187 @@ +"""Tests for PrepGripperArm resource/coordinate pick and drop helpers.""" + +from __future__ import annotations + +import asyncio +from typing import Any, List, Optional +from unittest.mock import AsyncMock + +import pytest + +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck + + +def _make_arm(deck: PrepDeck) -> PrepGripperArm: + backend = PrepGripper(client=AsyncMock(), channels=AsyncMock()) + backend.pick_up_at_location = AsyncMock() # type: ignore[method-assign] + backend.drop_at_location = AsyncMock() # type: ignore[method-assign] + return PrepGripperArm(backend=backend, reference_resource=deck, grip_axis="y") + + +def test_drop_location_matches_holder_geometry_and_offset(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + + pdfb = arm._resolve_pickup_distance(plate, None) + arm._held_resource = plate + arm._pickup_distance_from_bottom = pdfb + arm._holding_resource_width = arm._resource_width(plate) + + offset = Coordinate(1.0, 2.0, 3.0) + got = arm._drop_location(dest, offset) + + expected = ( + dest.get_absolute_location("l", "f", "b") + + dest.get_default_child_location(plate) + + plate.center() + + offset + + Coordinate(0, 0, pdfb) + ) + assert got.x == pytest.approx(expected.x) + assert got.y == pytest.approx(expected.y) + assert got.z == pytest.approx(expected.z) + + +def test_drop_resource_not_holding_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + with pytest.raises(RuntimeError, match="Not holding anything"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_after_coordinate_pick_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(100, 200, 50), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + with pytest.raises(RuntimeError, match="pick_up_resource"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_reassigns_holder(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped: List[Coordinate] = [] + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + del resource_width, kwargs + dropped.append(location) + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate) + assert plate.parent is deck[4] + await arm.drop_resource(dest) + assert plate.parent is dest + assert dest.resource is plate + assert deck[4].resource is None + assert arm._held_resource is None + assert arm._holding_resource_width is None + assert len(dropped) == 1 + + asyncio.run(_run()) + + +def test_pick_up_resource_width_override(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + arm = _make_arm(deck) + captured: dict[str, Any] = {} + + async def _capture_pick( + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + del location, resource_length, resource_height, plate_top_z_offset + del clearance_y, grip_speed_y, squeeze_mm + captured["resource_width"] = resource_width + + arm.backend.pick_up_at_location = _capture_pick # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate, resource_width=80.5) + assert captured["resource_width"] == 80.5 + assert arm._holding_resource_width == 80.5 + + asyncio.run(_run()) + + +def test_pick_up_at_location_enables_drop_at_location(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + place = Coordinate(10, 20, 30) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(1, 2, 3), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + assert arm._holding_resource_width == 85.0 + assert arm._held_resource is None + await arm.drop_at_location(place) + arm.backend.drop_at_location.assert_awaited_once() # type: ignore[attr-defined] + args = arm.backend.drop_at_location.await_args # type: ignore[attr-defined] + assert args is not None + assert args.args[0] == place + assert args.args[1] == 85.0 + assert arm._holding_resource_width is None + + asyncio.run(_run()) + + +def test_drop_resource_applies_offset_to_firmware_location(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped_loc: Optional[Coordinate] = None + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + nonlocal dropped_loc + del resource_width, kwargs + dropped_loc = location + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + offset = Coordinate(0.5, -0.25, 1.0) + + async def _run() -> None: + await arm.pick_up_resource(plate) + expected = arm._drop_location(dest, offset) + await arm.drop_resource(dest, offset=offset) + assert dropped_loc is not None + assert dropped_loc.x == pytest.approx(expected.x) + assert dropped_loc.y == pytest.approx(expected.y) + assert dropped_loc.z == pytest.approx(expected.z) + + asyncio.run(_run()) From 61e8e582f3003ed095cdb7830f8e5ad83b232e50 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:05:56 -0700 Subject: [PATCH 05/13] feat(hamilton.prep): switch channels and head8 to resource-facing APIs Delete prep/standard op dataclasses; peers take TipSpots/wells + vols with kwargs instead of constructing op objects. --- pylabrobot/hamilton/prep/channels.py | 269 ++++++++++----- pylabrobot/hamilton/prep/head8.py | 306 ++++++++++++------ pylabrobot/hamilton/prep/method.py | 4 +- pylabrobot/hamilton/prep/prep_commands.py | 10 +- pylabrobot/hamilton/prep/standard.py | 248 -------------- pylabrobot/hamilton/prep/tests/head8_tests.py | 160 +++------ 6 files changed, 441 insertions(+), 556 deletions(-) delete mode 100644 pylabrobot/hamilton/prep/standard.py diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py index 1293da30dc2..09677c871c2 100644 --- a/pylabrobot/hamilton/prep/channels.py +++ b/pylabrobot/hamilton/prep/channels.py @@ -38,17 +38,11 @@ corrected_volumes_for_ops, resolve_hamilton_liquid_classes, ) -from pylabrobot.hamilton.prep.standard import ( - Aspiration, - Dispense, - Pickup, - TipDrop, -) from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError from pylabrobot.hamilton.transport.tcp.packets import Address from pylabrobot.legacy.liquid_handling.errors import ChannelizedError from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass -from pylabrobot.resources import Coordinate, Tip +from pylabrobot.resources import Container, Coordinate, Tip from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers from pylabrobot.resources.tip_rack import TipSpot @@ -67,7 +61,22 @@ logger = logging.getLogger(__name__) _T = TypeVar("_T") -_OpT = TypeVar("_OpT", Aspiration, Dispense) + + +@dataclass +class _PipetteTransfer: + """Private snapshot for aspirate/dispense resolution (not a public standard op).""" + + resource: Container + tip: Tip + volume: float + offset: Coordinate + liquid_height: Optional[float] = None + flow_rate: Optional[float] = None + blow_out_air_volume: Optional[float] = None + + +_OpT = TypeVar("_OpT", bound=_PipetteTransfer) # ============================================================================= @@ -796,6 +805,7 @@ def __init__( self._supports_v2_pipetting: Optional[bool] = None self.setup_finished: bool = False self.channels: List[PrepPIPChannel] = [] + self._mounted_tips: dict[int, Tip] = {} 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. @@ -954,11 +964,21 @@ def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: # Tip / aspirate / dispense API # --------------------------------------------------------------------------- + def _require_mounted_tips(self, use_channels: List[int]) -> List[Tip]: + tips: List[Tip] = [] + for ch in use_channels: + tip = self._mounted_tips.get(ch) + if tip is None: + raise RuntimeError(f"No tip mounted on channel {ch}; call pick_up_tips first.") + tips.append(tip) + return tips + async def pick_up_tips( self, - ops: List[Pickup], - use_channels: List[int], + tip_spots: Sequence[TipSpot], + use_channels: Optional[List[int]] = None, *, + offsets: Optional[Sequence[Coordinate]] = None, final_z: Optional[float] = None, seek_speed: float = 15.0, z_seek_offset: Optional[float] = None, @@ -968,47 +988,68 @@ async def pick_up_tips( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, pre_position: bool = True, ): - """Pick up tips. + """Pick up tips from tip spots. 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). """ - assert len(ops) == len(use_channels) + tip_spots = list(tip_spots) + use_channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + if len(tip_spots) != len(use_channels): + raise ValueError( + f"len(tip_spots) must equal len(use_channels): {len(tip_spots)} != {len(use_channels)}" + ) if use_channels: assert max(use_channels) < self.num_channels, ( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) + offsets_list = ( + list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) + ) + if len(offsets_list) != len(tip_spots): + raise ValueError("len(offsets) must equal len(tip_spots)") + tips = [spot.get_tip() for spot in tip_spots] resolved_final_z = self._resolve_traverse_height(final_z) - indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + indexed = { + ch: (spot, tip, off) + for ch, spot, tip, off in zip(use_channels, tip_spots, tips, offsets_list) + } tip_positions: List[PrepCmd.TipPositionParameters] = [] for ch in range(self.num_channels): - if ch not in indexed_ops: + if ch not in indexed: continue - op = indexed_ops[ch] - loc = op.resource.get_absolute_location("c", "c", "t") - params = PrepCmd.TipPositionParameters.for_op( - _CHANNEL_INDEX[ch], loc, op.resource.get_tip(), z_seek_offset=z_seek_offset + spot, tip, off = indexed[ch] + loc = spot.get_absolute_location("c", "c", "t") + off + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], loc, 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 + tip0 = tips[0] + if any( + t.maximal_volume != tip0.maximal_volume + or t.has_filter != tip0.has_filter + or (t.total_tip_length - t.fitting_depth) != (tip0.total_tip_length - tip0.fitting_depth) + for t in tips + ): + raise ValueError("All tip spots must use the same tip type") tip_definition = PrepCmd.TipPickupParameters( default_values=False, - volume=tip.maximal_volume, - length=tip.total_tip_length - tip.fitting_depth, + volume=tip0.maximal_volume, + length=tip0.total_tip_length - tip0.fitting_depth, tip_type=PrepCmd.TipTypes.StandardVolume, - has_filter=tip.has_filter, + has_filter=tip0.has_filter, is_needle=False, is_tool=False, ) if pre_position: traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z - locs = [indexed_ops[ch].resource.get_absolute_location("c", "c", "t") for ch in use_channels] + locs = [indexed[ch][0].get_absolute_location("c", "c", "t") + indexed[ch][2] for ch in use_channels] await self.move_to_position( x=locs[0].x, y=[loc.y for loc in locs], @@ -1027,48 +1068,64 @@ async def pick_up_tips( dispenser_speed=dispenser_speed, ) ) + for ch, tip in zip(use_channels, tips): + self._mounted_tips[ch] = tip async def drop_tips( self, - ops: List[TipDrop], - use_channels: List[int], + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, *, + offsets: Optional[Sequence[Coordinate]] = None, final_z: Optional[float] = None, seek_speed: float = 15.0, z_seek_offset: Optional[float] = None, drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, tip_roll_off_distance: float = 0.0, ): - """Drop tips. + """Drop tips to tip spots or trash. The arm moves to z_seek during lateral XY approach (tip is on pipette, so tip 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. """ - assert len(ops) == len(use_channels) + destinations = list(destinations) + use_channels = use_channels if use_channels is not None else list(range(len(destinations))) + if len(destinations) != len(use_channels): + raise ValueError( + f"len(destinations) must equal len(use_channels): " + f"{len(destinations)} != {len(use_channels)}" + ) if use_channels: assert max(use_channels) < self.num_channels, ( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) + tips = self._require_mounted_tips(use_channels) + offsets_list = ( + list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) + ) + if len(offsets_list) != len(destinations): + raise ValueError("len(offsets) must equal len(destinations)") - all_trash = all(isinstance(op.resource, Trash) for op in ops) - all_tip_spots = all(isinstance(op.resource, TipSpot) for op in ops) + all_trash = all(isinstance(d, Trash) for d in destinations) + all_tip_spots = all(isinstance(d, TipSpot) for d in destinations) 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 = PrepCmd.TipDropType.Stall if all_trash else drop_type - indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + indexed = { + ch: (dest, tip, off) + for ch, dest, tip, off in zip(use_channels, destinations, tips, offsets_list) + } tip_positions: List[PrepCmd.TipDropParameters] = [] for ch in range(self.num_channels): - if ch not in indexed_ops: + if ch not in indexed: continue - op = indexed_ops[ch] - tip = op.tip + dest, tip, off = indexed[ch] if all_trash: if self.deck is None: raise ValueError( @@ -1082,11 +1139,12 @@ 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 = PrepCmd.TipDropParameters.for_op( - _CHANNEL_INDEX[ch], loc, tip, z_seek_offset=z_seek_offset, drop_type=resolved_drop_type + loc = dest.get_absolute_location("c", "c", "t") + off + tip_positions.append( + 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( PrepCmd.PrepDropTips( @@ -1096,6 +1154,8 @@ async def drop_tips( tip_roll_off_distance=roll_off, ) ) + for ch in use_channels: + self._mounted_tips.pop(ch, None) # --------------------------------------------------------------------------- # V1/V2 aspirate/dispense dispatch helpers @@ -1251,7 +1311,7 @@ def _resolve_channel_context( def _resolve_aspirate_channels( self, - ops: List[Aspiration], + ops: List[_PipetteTransfer], use_channels: List[int], effective_lld: bool, *, @@ -1328,8 +1388,8 @@ def _resolve_aspirate_channels( kits.append( _AspirateChannelKit( channel=_CHANNEL_INDEX[ch], - aspirate=PrepCmd.AspirateParameters.for_op( - loc, asp, prewet_volume=prewet_volume[idx], blowout_volume=blowout_volumes[idx] + aspirate=PrepCmd.AspirateParameters.from_location( + loc, prewet_volume=prewet_volume[idx], blowout_volume=blowout_volumes[idx] ), common=PrepCmd.CommonParameters.for_op( ctx.volumes[idx], @@ -1513,7 +1573,7 @@ async def _send_aspirate( def _resolve_dispense_channels( self, - ops: List[Dispense], + ops: List[_PipetteTransfer], use_channels: List[int], effective_lld: bool, *, @@ -1700,11 +1760,53 @@ async def _send_dispense( # Public aspirate / dispense orchestrators # --------------------------------------------------------------------------- - async def aspirate( + def _build_transfers( self, - ops: List[Aspiration], + resources: Sequence[Container], + vols: Sequence[float], use_channels: List[int], *, + offsets: Optional[Sequence[Coordinate]] = None, + liquid_height: Optional[Sequence[Optional[float]]] = None, + flow_rates: Optional[Sequence[Optional[float]]] = None, + blow_out_air_volume: Optional[Sequence[Optional[float]]] = None, + ) -> List[_PipetteTransfer]: + resources = list(resources) + vols = [float(v) for v in vols] + if len(resources) != len(use_channels) or len(vols) != len(use_channels): + raise ValueError("resources, vols, and use_channels must have the same length") + tips = self._require_mounted_tips(use_channels) + n = len(use_channels) + offs = list(offsets) if offsets is not None else [Coordinate.zero()] * n + lhs = list(liquid_height) if liquid_height is not None else [None] * n + frs = list(flow_rates) if flow_rates is not None else [None] * n + bavs = list(blow_out_air_volume) if blow_out_air_volume is not None else [None] * n + for name, seq in (("offsets", offs), ("liquid_height", lhs), ("flow_rates", frs), ("blow_out_air_volume", bavs)): + if len(seq) != n: + raise ValueError(f"{name} length must match use_channels ({n})") + return [ + _PipetteTransfer( + resource=r, + tip=t, + volume=v, + offset=o, + liquid_height=lh, + flow_rate=fr, + blow_out_air_volume=bav, + ) + for r, t, v, o, lh, fr, bav in zip(resources, tips, vols, offs, lhs, frs, bavs) + ] + + async def aspirate( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, + *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, z_final: Optional[List[float]] = None, z_fluid: Optional[List[float]] = None, z_air: Optional[List[float]] = None, @@ -1726,31 +1828,22 @@ async def aspirate( read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, ): - """Aspirate, dispatching to the appropriate command variant and version. - - Selects the command variant based on ``lld_mode`` (LLD on/off) and - ``tadm`` presence (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 - 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): 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: - ops: :class:`~pylabrobot.hamilton.prep.standard.Aspiration` ops - (``mix`` uses :class:`~pylabrobot.hamilton.prep.standard.Mix`). - - Example:: + """Aspirate from containers using mounted tips. - await channels.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) - await channels.aspirate(ops, [0], lld_mode=[LLDMode.CAPACITIVE]) - await channels.aspirate(ops, [0], tadm=PrepCmd.TadmParameters.default()) - await channels.aspirate(ops, [0], command_version="v1") + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) effective_lld = self._resolve_effective_lld(lld_mode, lld, len(ops)) is_tadm = tadm is not None use_v2 = self._resolve_command_version(command_version) @@ -1787,9 +1880,14 @@ async def aspirate( async def dispense( self, - ops: List[Dispense], - use_channels: List[int], + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, z_final: Optional[List[float]] = None, z_fluid: Optional[List[float]] = None, z_air: Optional[List[float]] = None, @@ -1810,23 +1908,22 @@ async def dispense( read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, ): - """Dispense, dispatching to the appropriate command variant and version. - - The Prep firmware has 2 dispense commands (NoLld, Lld) — unlike aspirate which - splits into 4 (NoLld+Monitoring, NoLld+Tadm, Lld+Monitoring, Lld+Tadm). Both - dispense structs always carry a TADM field (sent with ``default_values=True`` - when not explicitly configured), so TADM is always available on dispense - regardless of whether ``tadm=`` is passed. + """Dispense to containers using mounted tips. - Args: - ops: :class:`~pylabrobot.hamilton.prep.standard.Dispense` ops. - - Example:: - - await channels.dispense(ops, [0], z_final=[95.0], settling_time=[0.5]) - await channels.dispense(ops, [0], lld_mode=[LLDMode.CAPACITIVE]) - await channels.dispense(ops, [0], command_version="v1") + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) effective_lld = self._resolve_effective_lld( lld_mode, lld, len(ops), allowed_modes=_DISPENSE_ALLOWED_LLD diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py index 74711d18d46..3a67621c9e6 100644 --- a/pylabrobot/hamilton/prep/head8.py +++ b/pylabrobot/hamilton/prep/head8.py @@ -22,9 +22,16 @@ import logging import struct as _struct -from typing import TYPE_CHECKING, List, Literal, Optional, Union +from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Union -from pylabrobot.resources import Trash +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Container, Coordinate, Tip, Trash +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.well import Well from . import prep_commands as PrepCmd from .channels import ( @@ -50,14 +57,6 @@ resolve_command_version as _resolve_command_version_fn, ) from .client import MPH_OBJECT_PATH -from .standard import ( - Head8AspirationContainer, - Head8AspirationWells, - Head8DispenseContainer, - Head8DispenseWells, - Head8TipDrop, - Head8TipPickup, -) if TYPE_CHECKING: from .client import PrepClient @@ -115,6 +114,7 @@ def __init__( self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense self.channels: list = [] # populated by build_prep_channels after construction self._supports_v2_pipetting: Optional[bool] = None + self._mounted_tips: list[Tip] = [] # --------------------------------------------------------------------------- # Setup / V2 probing @@ -147,6 +147,7 @@ async def _on_setup(self) -> None: async def _on_stop(self) -> None: self._supports_v2_pipetting = None + self._mounted_tips = [] # --------------------------------------------------------------------------- # Internal helpers @@ -653,10 +654,17 @@ async def move_to_position( # Tip / aspirate / dispense # --------------------------------------------------------------------------- + def _require_mounted_tip(self) -> Tip: + if not self._mounted_tips: + raise RuntimeError("No tips mounted on head8; call pick_up_tips8 first.") + return self._mounted_tips[0] + async def pick_up_tips8( self, - op: Head8TipPickup, + tip_spots: Sequence[TipSpot], + use_channels: Optional[Sequence[int]] = None, *, + offset: Coordinate = Coordinate.zero(), final_z: Optional[float] = None, seek_speed: float = 15.0, z_seek_offset: Optional[float] = None, @@ -666,30 +674,28 @@ async def pick_up_tips8( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, pre_position: bool = True, ) -> None: - use_channels = list(op.use_channels) + tip_spots = list(tip_spots) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) self._require_all_channels(use_channels, "pick_up_tips8") + if len(tip_spots) != NUM_PROBES: + raise ValueError(f"pick_up_tips8 requires {NUM_PROBES} tip spots, got {len(tip_spots)}") resolved_final_z = self._resolve_traverse_height(final_z) - ref_spot = op.tip_spots[0] + tips = [s.get_tip() for s in tip_spots] + ref_spot = tip_spots[0] + tip = tips[0] rack = ref_spot.parent logger.info( "[Prep MPH] pick_up_tips: rack=%s, tip_spots=%s", rack.name if rack is not None else ref_spot.name, - [s.name.rsplit("_", 1)[-1] for s in op.tip_spots], + [s.name.rsplit("_", 1)[-1] for s in tip_spots], ) - # Use the tip from the struct — the spot tracker is already cleared by Head8 before - # this method is invoked, so ref_spot.get_tip() would fail. - tip = op.tips[0] - if tip is None: - raise RuntimeError("pick_up_tips8: first spot has no tip") - loc = ref_spot.get_absolute_location("c", "c", "t") + loc = ref_spot.get_absolute_location("c", "c", "t") + offset - # Pre-position uses the same absolute frame as tip_position below (probe 0 / row A). if pre_position: traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z await self.move_to_position(loc.x, loc.y, traverse_h) - # tip_spots[0] is always row-A (probe 0, highest Y) since all 8 channels are required. tip_position = PrepCmd.TipPositionParameters.for_op( PrepCmd.ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset ) @@ -714,37 +720,39 @@ async def pick_up_tips8( tip_mask=_FULL_TIP_MASK, ) ) + self._mounted_tips = tips async def drop_tips8( self, - op: Head8TipDrop, + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[Sequence[int]] = None, *, + offset: Coordinate = Coordinate.zero(), final_z: Optional[float] = None, seek_speed: float = 15.0, z_seek_offset: Optional[float] = None, tip_roll_off_distance: float = 0.0, ) -> None: - use_channels = list(op.use_channels) + destinations = list(destinations) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) self._require_all_channels(use_channels, "drop_tips8") + if len(destinations) != NUM_PROBES: + raise ValueError(f"drop_tips8 requires {NUM_PROBES} destinations, got {len(destinations)}") + tip = self._require_mounted_tip() resolved_final_z = self._resolve_traverse_height(final_z) - ref_spot = op.resources[0] + ref_spot = destinations[0] is_trash = isinstance(ref_spot, Trash) dest = ref_spot if is_trash else ref_spot.parent logger.info( "[Prep MPH] drop_tips: dest=%s, resources=%s", dest.name if dest is not None else ref_spot.name, - [s.name.rsplit("_", 1)[-1] for s in op.resources], + [s.name.rsplit("_", 1)[-1] for s in destinations], ) - tip = op.tips[0] - if tip is None: - raise RuntimeError("drop_tips8: no tip on first channel") - # resources[0] = probe 0 (row A, highest Y). Use "c","c","t" consistently for - # both tip spots and trash — matches PrepChannels.drop_tips and TipDropParameters.for_op. loc = ref_spot.get_absolute_location("c", "c", "t") if not is_trash: - loc = loc + op.offset + loc = loc + offset drop_type = PrepCmd.TipDropType.Stall if is_trash else PrepCmd.TipDropType.FixedHeight tip_position = PrepCmd.TipDropParameters.for_op( @@ -763,11 +771,19 @@ async def drop_tips8( tip_roll_off_distance=roll_off, ) ) + self._mounted_tips = [] async def aspirate8( self, - op: Union[Head8AspirationWells, Head8AspirationContainer], + wells: Optional[Sequence[Well]] = None, *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, z_final: Optional[float] = None, z_fluid: Optional[float] = None, z_air: Optional[float] = None, @@ -784,46 +800,72 @@ async def aspirate8( tadm: Optional[PrepCmd.TadmParameters] = None, container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]]] = None, + disable_volume_correction: bool = False, read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, ) -> None: - use_channels = list(op.use_channels) + del offset # geometry uses well/container absolute locations + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) self._require_all_channels(use_channels, "aspirate8") - tip = next((t for t in op.tips if t is not None), None) + if (wells is None) == (container is None): + raise ValueError("aspirate8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops( + [tip_vol], hlcs, [disable_volume_correction] + )[0] + traverse_z = self._resolve_traverse_height() - final_z = ( + final_z_resolved = ( z_final if z_final is not None - else ( - traverse_z - (tip.total_tip_length - tip.fitting_depth) if tip is not None else traverse_z - ) + else traverse_z - (tip.total_tip_length - tip.fitting_depth) ) - op_targets: Union[str, List[str]] - if isinstance(op, Head8AspirationContainer): - container = op.container + if container is not None: self._validate_container_span(container) resource_name = container.parent.name if container.parent is not None else container.name - op_targets = container.name + op_targets: Union[str, List[str]] = container.name loc = container.get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM - wg = _absolute_z_from_well(container, op.liquid_height) + wg = _absolute_z_from_well(container, liquid_height) ref_segments = container_segments or ( _build_container_segments(container) if auto_container_geometry else [] ) ref_resource = container else: - wells = op.wells - self._resolve_probe_positions(wells) # validates 9mm pitch; raises on mismatch - resource_name = wells[0].parent.name if wells[0].parent is not None else wells[0].name - op_targets = [w.name.rsplit("_", 1)[-1] for w in wells] - ref_loc = wells[0].get_absolute_location("c", "c", "cavity_bottom") + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"aspirate8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = ref_loc.x, ref_loc.y - wg = _absolute_z_from_well(wells[0], op.liquid_height) + wg = _absolute_z_from_well(wells_list[0], liquid_height) ref_segments = container_segments or ( - _build_container_segments(wells[0]) if auto_container_geometry else [] + _build_container_segments(wells_list[0]) if auto_container_geometry else [] ) - ref_resource = wells[0] + ref_resource = wells_list[0] resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface resolved_z_air = z_air if z_air is not None else wg.z_air @@ -831,20 +873,43 @@ async def aspirate8( resolved_z_bottom_search_offset = ( z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 ) - resolved_settling_time = settling_time if settling_time is not None else 1.0 + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.aspiration_settling_time if hlc is not None else 1.0) + ) resolved_transport_air_volume = ( - transport_air_volume if transport_air_volume is not None else 0.0 + transport_air_volume + if transport_air_volume is not None + else (hlc.aspiration_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.aspiration_swap_speed if hlc is not None else 10.0) + ) + resolved_prewet_volume = ( + prewet_volume + if prewet_volume is not None + else (hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0) + ) + resolved_flow = ( + flow_rate + if flow_rate is not None + else (hlc.aspiration_flow_rate if hlc is not None else 100.0) + ) + blowout_volume = ( + blow_out_air_volume + if blow_out_air_volume is not None + else (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) ) - resolved_z_liquid_exit_speed = z_liquid_exit_speed if z_liquid_exit_speed is not None else 10.0 - resolved_prewet_volume = prewet_volume if prewet_volume is not None else 0.0 - blowout_volume = op.blow_out_air_volume or 0.0 logger.info( "[Prep MPH] aspirate: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", resource_name, op_targets, - op.volume, - round(op.flow_rate, 3) if op.flow_rate is not None else None, + corrected, + round(resolved_flow, 3), ) tube_radius = _effective_radius(ref_resource) @@ -860,9 +925,9 @@ async def aspirate8( param_struct = assemble( ref_x=ref_x, ref_y=ref_y, - volume=op.volume, + volume=corrected, tube_radius=tube_radius, - final_z=final_z, + final_z=final_z_resolved, z_minimum=resolved_z_minimum, z_fluid=resolved_z_fluid, z_air=resolved_z_air, @@ -872,7 +937,7 @@ async def aspirate8( z_liquid_exit_speed=resolved_z_liquid_exit_speed, prewet_volume=resolved_prewet_volume, blowout_volume=blowout_volume, - flow_rate=op.flow_rate, + flow_rate=resolved_flow, segments=ref_segments, effective_lld=effective_lld, is_tadm=is_tadm, @@ -894,8 +959,15 @@ async def aspirate8( async def dispense8( self, - op: Union[Head8DispenseWells, Head8DispenseContainer], + wells: Optional[Sequence[Well]] = None, *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, z_final: Optional[float] = None, z_fluid: Optional[float] = None, z_air: Optional[float] = None, @@ -911,46 +983,73 @@ async def dispense8( c_lld: Optional[PrepCmd.CLldParameters] = None, container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]]] = None, + disable_volume_correction: bool = False, read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, ) -> None: - use_channels = list(op.use_channels) + del offset + del blow_out_air_volume # dispense blowout not on Prep dispense wire path today + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) self._require_all_channels(use_channels, "dispense8") - tip = next((t for t in op.tips if t is not None), None) + if (wells is None) == (container is None): + raise ValueError("dispense8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops( + [tip_vol], hlcs, [disable_volume_correction] + )[0] + traverse_z = self._resolve_traverse_height() - final_z = ( + final_z_resolved = ( z_final if z_final is not None - else ( - traverse_z - (tip.total_tip_length - tip.fitting_depth) if tip is not None else traverse_z - ) + else traverse_z - (tip.total_tip_length - tip.fitting_depth) ) - op_targets: Union[str, List[str]] - if isinstance(op, Head8DispenseContainer): - container = op.container + if container is not None: self._validate_container_span(container) resource_name = container.parent.name if container.parent is not None else container.name - op_targets = container.name + op_targets: Union[str, List[str]] = container.name loc = container.get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM - wg = _absolute_z_from_well(container, op.liquid_height) + wg = _absolute_z_from_well(container, liquid_height) ref_segments = container_segments or ( _build_container_segments(container) if auto_container_geometry else [] ) ref_resource = container else: - wells = op.wells - self._resolve_probe_positions(wells) # validates 9mm pitch; raises on mismatch - resource_name = wells[0].parent.name if wells[0].parent is not None else wells[0].name - op_targets = [w.name.rsplit("_", 1)[-1] for w in wells] - ref_loc = wells[0].get_absolute_location("c", "c", "cavity_bottom") + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"dispense8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = ref_loc.x, ref_loc.y - wg = _absolute_z_from_well(wells[0], op.liquid_height) + wg = _absolute_z_from_well(wells_list[0], liquid_height) ref_segments = container_segments or ( - _build_container_segments(wells[0]) if auto_container_geometry else [] + _build_container_segments(wells_list[0]) if auto_container_geometry else [] ) - ref_resource = wells[0] + ref_resource = wells_list[0] resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface resolved_z_air = z_air if z_air is not None else wg.z_air @@ -958,20 +1057,43 @@ async def dispense8( resolved_z_bottom_search_offset = ( z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 ) - resolved_settling_time = settling_time if settling_time is not None else 0.0 + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.dispense_settling_time if hlc is not None else 0.0) + ) resolved_transport_air_volume = ( - transport_air_volume if transport_air_volume is not None else 0.0 + transport_air_volume + if transport_air_volume is not None + else (hlc.dispense_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.dispense_swap_speed if hlc is not None else 10.0) + ) + resolved_stop_back_volume = ( + stop_back_volume + if stop_back_volume is not None + else (hlc.dispense_stop_back_volume if hlc is not None else 0.0) + ) + resolved_cutoff_speed = ( + cutoff_speed + if cutoff_speed is not None + else (hlc.dispense_stop_flow_rate if hlc is not None else 100.0) + ) + resolved_flow = ( + flow_rate + if flow_rate is not None + else (hlc.dispense_flow_rate if hlc is not None else 100.0) ) - resolved_z_liquid_exit_speed = z_liquid_exit_speed if z_liquid_exit_speed is not None else 10.0 - resolved_stop_back_volume = stop_back_volume if stop_back_volume is not None else 0.0 - resolved_cutoff_speed = cutoff_speed if cutoff_speed is not None else 100.0 logger.info( "[Prep MPH] dispense: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", resource_name, op_targets, - op.volume, - round(op.flow_rate, 3) if op.flow_rate is not None else None, + corrected, + round(resolved_flow, 3), ) tube_radius = _effective_radius(ref_resource) @@ -986,9 +1108,9 @@ async def dispense8( param_struct = assemble( ref_x=ref_x, ref_y=ref_y, - volume=op.volume, + volume=corrected, tube_radius=tube_radius, - final_z=final_z, + final_z=final_z_resolved, z_minimum=resolved_z_minimum, z_fluid=resolved_z_fluid, z_air=resolved_z_air, @@ -998,7 +1120,7 @@ async def dispense8( z_liquid_exit_speed=resolved_z_liquid_exit_speed, stop_back_volume=resolved_stop_back_volume, cutoff_speed=resolved_cutoff_speed, - flow_rate=op.flow_rate, + flow_rate=resolved_flow, segments=ref_segments, effective_lld=effective_lld, lld_params=lld_params, diff --git a/pylabrobot/hamilton/prep/method.py b/pylabrobot/hamilton/prep/method.py index 82fe89d2209..c338becf657 100644 --- a/pylabrobot/hamilton/prep/method.py +++ b/pylabrobot/hamilton/prep/method.py @@ -42,8 +42,8 @@ async def run(self, automatic_pause: bool = False) -> AsyncIterator["PrepMethodL Usage:: async with prep.method.run(): - await prep.pip.pick_up_tips(...) - await prep.pip.aspirate(...) + await prep.channels.pick_up_tips(...) + await prep.channels.aspirate(...) """ await self.begin(automatic_pause=automatic_pause) try: diff --git a/pylabrobot/hamilton/prep/prep_commands.py b/pylabrobot/hamilton/prep/prep_commands.py index 95f902abed7..1ea8e822e87 100644 --- a/pylabrobot/hamilton/prep/prep_commands.py +++ b/pylabrobot/hamilton/prep/prep_commands.py @@ -39,8 +39,6 @@ Enum as WEnum, ) -from .standard import Aspiration - # ============================================================================= # Enums (mirrored from Prep protocol spec) # ============================================================================= @@ -474,19 +472,19 @@ class AspirateParameters: blowout_volume: F32 @classmethod - def for_op( + def from_location( cls, loc, - op: Aspiration, + *, prewet_volume: float = 0.0, - blowout_volume: Optional[float] = None, + blowout_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) if blowout_volume is None else blowout_volume, + blowout_volume=blowout_volume, ) diff --git a/pylabrobot/hamilton/prep/standard.py b/pylabrobot/hamilton/prep/standard.py deleted file mode 100644 index 72202342286..00000000000 --- a/pylabrobot/hamilton/prep/standard.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Operation types for Hamilton Prep liquid handling.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union - -from pylabrobot.resources import Coordinate - -if TYPE_CHECKING: - from pylabrobot.resources import Container, Tip, TipRack, TipSpot, Trash, Well - - -@dataclass(frozen=True) -class Mix: - """Mix parameters for aspiration/dispense operations.""" - - volume: float - repetitions: int - flow_rate: float - - -# --------------------------------------------------------------------------- -# Independent channel operations -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Pickup: - """Pick up a tip from a tip spot.""" - - resource: TipSpot - offset: Coordinate - tip: Tip - - -@dataclass(frozen=True) -class TipDrop: - """Drop a tip to a tip spot or trash.""" - - resource: Union[TipSpot, Trash] - offset: Coordinate - tip: Tip - - -@dataclass(frozen=True) -class Aspiration: - """Aspirate liquid from a container using an independent channel.""" - - resource: Container - offset: Coordinate - tip: Tip - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class Dispense: - """Dispense liquid to a container using an independent channel.""" - - resource: Container - offset: Coordinate - tip: Tip - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -# --------------------------------------------------------------------------- -# 96-head operations -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class PickupTipRack: - """Pick up tips from a tip rack using the 96-head.""" - - resource: TipRack - offset: Coordinate - tips: Sequence[Optional[Tip]] - - -@dataclass(frozen=True) -class DropTipRack: - """Drop tips to a tip rack or trash using the 96-head.""" - - resource: Union[TipRack, Trash] - offset: Coordinate - - -@dataclass(frozen=True) -class MultiHeadAspirationPlate: - """Aspirate from wells in a plate using the 96-head.""" - - wells: List[Well] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadDispensePlate: - """Dispense to wells in a plate using the 96-head.""" - - wells: List[Well] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadAspirationContainer: - """Aspirate from a single container (trough) using the 96-head.""" - - container: Container - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadDispenseContainer: - """Dispense to a single container (trough) using the 96-head.""" - - container: Container - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -# --------------------------------------------------------------------------- -# 8-head operations -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Head8TipPickup: - """Pick up tips with the 8MPH head. - - ``tip_spots[i]`` is the tip spot for active channel ``use_channels[i]``. - """ - - tip_spots: List[TipSpot] - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - - -@dataclass(frozen=True) -class Head8TipDrop: - """Drop tips with the 8MPH head. - - ``resources[i]`` is the destination (TipSpot or Trash) for active channel ``use_channels[i]``. - ``tips[i]`` carries the tip geometry so the backend can compute drop heights. - """ - - resources: List[Union[TipSpot, Trash]] - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - - -@dataclass(frozen=True) -class Head8AspirationWells: - """Aspirate from an explicit list of wells using the 8MPH head. - - ``wells[i]`` is the well for active channel ``use_channels[i]``. - Duplicate well entries are valid (e.g. 2 probes in one 24-well well). - """ - - wells: List[Well] - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class Head8DispenseWells: - """Dispense to an explicit list of wells using the 8MPH head. - - ``wells[i]`` is the well for active channel ``use_channels[i]``. - """ - - wells: List[Well] - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class Head8AspirationContainer: - """Aspirate from a single container (trough) using the 8MPH head.""" - - container: Container - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class Head8DispenseContainer: - """Dispense to a single container (trough) using the 8MPH head.""" - - container: Container - use_channels: Tuple[int, ...] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py index 938136983bd..d90aa37e6e2 100644 --- a/pylabrobot/hamilton/prep/tests/head8_tests.py +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -10,7 +10,7 @@ from __future__ import annotations import asyncio -from typing import Any, List, Sequence, Tuple +from typing import Any from unittest.mock import MagicMock import pytest @@ -22,24 +22,14 @@ _build_pipettor_gantry_move_parameters, ) from pylabrobot.hamilton.prep.head8 import PROBE_PITCH_MM, PrepHead8 -from pylabrobot.hamilton.prep.standard import ( - Head8AspirationWells, - Head8DispenseWells, - Head8TipDrop, - Head8TipPickup, -) from pylabrobot.resources import Coordinate from pylabrobot.resources.corning.axygen.plates import Cor_Axy_96_wellplate_500uL_Ub from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR -from pylabrobot.resources.tip_rack import TipSpot # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -_ALL8: Tuple[int, ...] = (0, 1, 2, 3, 4, 5, 6, 7) - - def _make_deck(): deck = PrepDeck() tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) @@ -48,73 +38,6 @@ def _make_deck(): return deck, tip_rack, src_plate, dst_plate -def _tips_from_spots(tip_spots: Sequence[TipSpot]) -> List[Any]: - return [s.get_tip() for s in tip_spots] - - -def _pickup_op( - tip_spots: Sequence[TipSpot], - use_channels: Tuple[int, ...] = _ALL8, -) -> Head8TipPickup: - return Head8TipPickup( - tip_spots=list(tip_spots), - use_channels=use_channels, - offset=Coordinate.zero(), - tips=_tips_from_spots(tip_spots), - ) - - -def _drop_op( - resources: Sequence[TipSpot], - tips: Sequence[Any], - use_channels: Tuple[int, ...] = _ALL8, -) -> Head8TipDrop: - return Head8TipDrop( - resources=list(resources), - use_channels=use_channels, - offset=Coordinate.zero(), - tips=tips, - ) - - -def _asp_wells_op( - wells: Sequence[Any], - tips: Sequence[Any], - volume: float, - use_channels: Tuple[int, ...] = _ALL8, -) -> Head8AspirationWells: - return Head8AspirationWells( - wells=list(wells), - use_channels=use_channels, - offset=Coordinate.zero(), - tips=tips, - volume=volume, - flow_rate=None, - liquid_height=None, - blow_out_air_volume=None, - mix=None, - ) - - -def _disp_wells_op( - wells: Sequence[Any], - tips: Sequence[Any], - volume: float, - use_channels: Tuple[int, ...] = _ALL8, -) -> Head8DispenseWells: - return Head8DispenseWells( - wells=list(wells), - use_channels=use_channels, - offset=Coordinate.zero(), - tips=tips, - volume=volume, - flow_rate=None, - liquid_height=None, - blow_out_air_volume=None, - mix=None, - ) - - def _make_head8() -> PrepHead8: return PrepHead8(client=None, info=None) # type: ignore[arg-type] @@ -213,7 +136,7 @@ async def _run() -> None: spots = tip_rack.column(1)[4:] # E2, F2, G2, H2 with pytest.raises(ValueError, match="fully-ganged head"): - await p.head8.pick_up_tips8(_pickup_op(spots, use_channels=(4, 5, 6, 7))) + await p.head8.pick_up_tips8(spots, use_channels=(4, 5, 6, 7)) await p.stop() @@ -242,11 +165,10 @@ async def _run() -> None: assert p.head8 is not None spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) - await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=20)) - await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=20)) - await p.head8.drop_tips8(_drop_op(spots, tips)) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=20) + await p.head8.dispense8(wells=dst_plate.column(0), volume=20) + await p.head8.drop_tips8(spots) await p.stop() @@ -317,7 +239,7 @@ async def _run() -> None: captured, _ = _record_send(p) - await p.head8.pick_up_tips8(_pickup_op(tip_rack.column(0))) + await p.head8.pick_up_tips8(tip_rack.column(0)) mph_seq = [ c for c in captured if isinstance(c, (PrepCmd.MphMoveToPosition, PrepCmd.MphPickupTips)) @@ -342,7 +264,7 @@ async def _run() -> None: captured, _ = _record_send(p) - await p.head8.pick_up_tips8(_pickup_op(tip_rack.column(1)), pre_position=False) + await p.head8.pick_up_tips8(tip_rack.column(1), pre_position=False) mph_moves = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] pickups = [c for c in captured if isinstance(c, PrepCmd.MphPickupTips)] @@ -364,17 +286,13 @@ async def _run() -> None: assert p.head8 is not None spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.pick_up_tips8(spots) with pytest.raises(ValueError, match="fully-ganged head"): await p.head8.aspirate8( - _asp_wells_op( - src_plate.column(0)[:4], - tips[:4], - volume=10, - use_channels=(0, 1, 2, 3), - ) + wells=src_plate.column(0)[:4], + volume=10, + use_channels=(0, 1, 2, 3), ) await p.stop() @@ -399,9 +317,8 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) - await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) asp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] v1_cmds = [ @@ -433,10 +350,9 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) - await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) - await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=10)) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) disp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] v1_cmds = [ @@ -464,10 +380,9 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) - await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) - await p.head8.dispense8(_disp_wells_op(dst_plate.column(0), tips, volume=10)) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] @@ -510,10 +425,10 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.pick_up_tips8(spots) await p.head8.aspirate8( - _asp_wells_op(src_plate.column(0), tips, volume=10), + wells=src_plate.column(0), + volume=10, tadm=PrepCmd.TadmParameters.default(), ) @@ -539,10 +454,10 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.pick_up_tips8(spots) await p.head8.aspirate8( - _asp_wells_op(src_plate.column(0), tips, volume=10), + wells=src_plate.column(0), + volume=10, lld_mode=LLDMode.CAPACITIVE, ) @@ -566,10 +481,10 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.pick_up_tips8(spots) await p.head8.aspirate8( - _asp_wells_op(src_plate.column(0), tips, volume=10), + wells=src_plate.column(0), + volume=10, lld_mode=LLDMode.CAPACITIVE, tadm=PrepCmd.TadmParameters.default(), ) @@ -592,13 +507,13 @@ async def _run() -> None: assert p.head8 is not None spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) - await p.head8.aspirate8(_asp_wells_op(src_plate.column(0), tips, volume=10)) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) with pytest.raises(ValueError, match="PRESSURE"): await p.head8.dispense8( - _disp_wells_op(dst_plate.column(0), tips, volume=10), + wells=dst_plate.column(0), + volume=10, lld_mode=LLDMode.PRESSURE, ) @@ -619,14 +534,15 @@ async def _run() -> None: captured, _ = _record_send(p) spots = tip_rack.column(0) - tips = _tips_from_spots(spots) - await p.head8.pick_up_tips8(_pickup_op(spots)) + await p.head8.pick_up_tips8(spots) await p.head8.aspirate8( - _asp_wells_op(src_plate.column(0), tips, volume=10), + wells=src_plate.column(0), + volume=10, command_version="v1", ) await p.head8.dispense8( - _disp_wells_op(dst_plate.column(0), tips, volume=10), + wells=dst_plate.column(0), + volume=10, command_version="v1", ) From 83fdb2cc8192f6f0e491f55a06bf6690733f920f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:05:41 -0700 Subject: [PATCH 06/13] feat(resources): add shared tip/volume/deck state finalize helpers Queue intents before device commands and commit or roll back from ChannelSuccesses so labware trackers stay consistent for deck and visualizer updates without vendor-specific bookkeeping. --- pylabrobot/resources/__init__.py | 13 ++ pylabrobot/resources/resource_state.py | 150 +++++++++++++++++ pylabrobot/resources/resource_state_tests.py | 165 +++++++++++++++++++ pylabrobot/resources/tip_tracker.py | 6 +- 4 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 pylabrobot/resources/resource_state.py create mode 100644 pylabrobot/resources/resource_state_tests.py diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index ca92f0edc5b..2ee7d95ac20 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -39,6 +39,19 @@ from .porvair import * from .powder import Powder from .resource import Resource +from .resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) from .resource_stack import ResourceStack from .revvity import * from .rotation import Rotation diff --git a/pylabrobot/resources/resource_state.py b/pylabrobot/resources/resource_state.py new file mode 100644 index 00000000000..ee1c480cdc8 --- /dev/null +++ b/pylabrobot/resources/resource_state.py @@ -0,0 +1,150 @@ +"""Shared tip / volume / deck state helpers for device peers. + +Devices adapt instrument outcomes into :data:`ChannelSuccesses` / bools, then call +these helpers. No vendor or transport imports — safe for Prep, Nimbus, and a future +LiquidHandler to share. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Collection, Literal, Mapping, Optional, Sequence, Union + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, does_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import does_volume_tracking + +ChannelSuccesses = Mapping[int, bool] + + +def all_channels_succeeded(use_channels: Sequence[int]) -> dict[int, bool]: + return {ch: True for ch in use_channels} + + +def successes_from_failed_channels( + use_channels: Sequence[int], + failed: Collection[int], +) -> dict[int, bool]: + failed_set = set(failed) + return {ch: ch not in failed_set for ch in use_channels} + + +@dataclass(frozen=True) +class TipPickupIntent: + channel: int + tip_spot: TipSpot + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class TipDropIntent: + channel: int + destination: Union[TipSpot, Trash] + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class VolumeTransferIntent: + channel: int + container: Container + tip: Tip + volume_ul: float + direction: Literal["aspirate", "dispense"] + + +def queue_tip_pickups(intents: Sequence[TipPickupIntent]) -> None: + """Queue spot remove + channel add (commit=False). Spot ops gated by tip tracking.""" + for intent in intents: + if intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} already has a tip") + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + intent.tip_spot.tracker.remove_tip(commit=False) + intent.channel_tracker.add_tip(intent.tip, origin=intent.tip_spot, commit=False) + + +def queue_tip_drops(intents: Sequence[TipDropIntent]) -> None: + """Queue channel remove; TipSpot destinations get the tip back. Trash: channel only.""" + for intent in intents: + if not intent.tip.tracker.is_disabled and intent.tip.tracker.get_used_volume() > 1e-6: + raise RuntimeError( + f"Cannot drop tip on channel {intent.channel} with volume " + f"{intent.tip.tracker.get_used_volume()} uL" + ) + if not intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} has no tip to drop") + intent.channel_tracker.remove_tip(commit=False) + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + intent.destination.tracker.add_tip(intent.tip, origin=None, commit=False) + + +def finalize_tip_ops( + intents: Sequence[Union[TipPickupIntent, TipDropIntent]], + successes: ChannelSuccesses, +) -> None: + for intent in intents: + ok = successes.get(intent.channel, False) + if isinstance(intent, TipPickupIntent): + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + (intent.tip_spot.tracker.commit if ok else intent.tip_spot.tracker.rollback)() + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + else: + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + (intent.destination.tracker.commit if ok else intent.destination.tracker.rollback)() + + +def queue_volume_transfers(intents: Sequence[VolumeTransferIntent]) -> None: + if not does_volume_tracking(): + return + for intent in intents: + if intent.direction == "aspirate": + if not intent.container.tracker.is_disabled: + intent.container.tracker.remove_liquid(intent.volume_ul) + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.add_liquid(intent.volume_ul) + else: + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.remove_liquid(intent.volume_ul) + if not intent.container.tracker.is_disabled: + intent.container.tracker.add_liquid(intent.volume_ul) + + +def finalize_volume_ops( + intents: Sequence[VolumeTransferIntent], + successes: ChannelSuccesses, +) -> None: + if not does_volume_tracking(): + return + for intent in intents: + ok = successes.get(intent.channel, False) + if not intent.container.tracker.is_disabled: + (intent.container.tracker.commit if ok else intent.container.tracker.rollback)() + if not intent.tip.tracker.is_disabled: + (intent.tip.tracker.commit if ok else intent.tip.tracker.rollback)() + + +def place_resource( + resource: Resource, + destination: Resource, + *, + location: Optional[Coordinate] = None, +) -> None: + """Reassign ``resource`` under ``destination`` after a successful place.""" + destination.check_can_drop_resource_here(resource) + resource.unassign() + if isinstance(destination, ResourceHolder): + destination.assign_child_resource(resource, location=location) + else: + destination.assign_child_resource( + resource, location=location if location is not None else Coordinate.zero() + ) diff --git a/pylabrobot/resources/resource_state_tests.py b/pylabrobot/resources/resource_state_tests.py new file mode 100644 index 00000000000..a5eb461549c --- /dev/null +++ b/pylabrobot/resources/resource_state_tests.py @@ -0,0 +1,165 @@ +"""Tests for shared tip / volume / deck resource state helpers.""" + +from __future__ import annotations + +import unittest + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, set_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import set_volume_tracking +from pylabrobot.resources.well import Well, WellBottomType + + +def _tip(name: str = "t") -> Tip: + return Tip( + has_filter=False, + total_tip_length=50, + maximal_volume=200, + fitting_depth=10, + name=name, + ) + + +def _spot(name: str = "spot") -> TipSpot: + spot = TipSpot(name=name, size_x=9, size_y=9, size_z=0, make_tip=_tip) + spot.tracker.add_tip(spot.make_tip(), origin=spot, commit=True) + return spot + + +class TestResourceStateTips(unittest.TestCase): + def setUp(self) -> None: + set_tip_tracking(True) + set_volume_tracking(False) + + def tearDown(self) -> None: + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pickup_commit_clears_spot_and_mounts_channel(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [ + TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel) + ] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: True}) + self.assertFalse(spot.has_tip()) + self.assertTrue(channel.has_tip) + self.assertIs(channel.get_tip(), tip) + + def test_pickup_rollback_restores_spot(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [ + TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel) + ] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: False}) + self.assertTrue(spot.has_tip()) + self.assertFalse(channel.has_tip) + + def test_drop_to_spot_and_trash(self) -> None: + spot = _spot("src") + dest = TipSpot(name="dest", size_x=9, size_y=9, size_z=0, make_tip=_tip) + trash = Trash(name="trash", size_x=10, size_y=10, size_z=10) + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + pick = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(pick) + finalize_tip_ops(pick, {0: True}) + + drop_spot = [ + TipDropIntent(channel=0, destination=dest, tip=tip, channel_tracker=channel) + ] + queue_tip_drops(drop_spot) + finalize_tip_ops(drop_spot, {0: True}) + self.assertTrue(dest.has_tip()) + self.assertFalse(channel.has_tip) + + tip2 = dest.get_tip() + pick2 = [TipPickupIntent(channel=0, tip_spot=dest, tip=tip2, channel_tracker=channel)] + queue_tip_pickups(pick2) + finalize_tip_ops(pick2, {0: True}) + drop_trash = [ + TipDropIntent(channel=0, destination=trash, tip=tip2, channel_tracker=channel) + ] + queue_tip_drops(drop_trash) + finalize_tip_ops(drop_trash, {0: True}) + self.assertFalse(channel.has_tip) + self.assertFalse(dest.has_tip()) + + def test_successes_from_failed_channels(self) -> None: + self.assertEqual( + successes_from_failed_channels([0, 1], {1: Exception("x")}), + {0: True, 1: False}, + ) + + +class TestResourceStateVolume(unittest.TestCase): + def setUp(self) -> None: + set_volume_tracking(True) + set_tip_tracking(False) + + def tearDown(self) -> None: + set_volume_tracking(False) + set_tip_tracking(False) + + def test_aspirate_commit(self) -> None: + well = Well( + name="w", + size_x=9, + size_y=9, + size_z=10, + bottom_type=WellBottomType.FLAT, + max_volume=200, + ) + well.tracker.set_volume(100) + tip = _tip() + intents = [ + VolumeTransferIntent( + channel=0, + container=well, + tip=tip, + volume_ul=25, + direction="aspirate", + ) + ] + queue_volume_transfers(intents) + finalize_volume_ops(intents, {0: True}) + self.assertAlmostEqual(well.tracker.get_used_volume(), 75) + self.assertAlmostEqual(tip.tracker.get_used_volume(), 25) + + +class TestPlaceResource(unittest.TestCase): + def test_place_onto_holder(self) -> None: + holder_a = ResourceHolder(name="a", size_x=100, size_y=100, size_z=10) + holder_b = ResourceHolder(name="b", size_x=100, size_y=100, size_z=10) + plate = Resource(name="p", size_x=127, size_y=85, size_z=14) + holder_a.assign_child_resource(plate) + place_resource(plate, holder_b) + self.assertIs(holder_b.resource, plate) + self.assertIsNone(holder_a.resource) + self.assertEqual(plate.location, Coordinate.zero()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 1a876ce86f5..4b1ee76b92c 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -57,15 +57,15 @@ def has_tip(self) -> bool: return self._pending_tip is not None def get_tip(self) -> "Tip": - """Get the tip. Note that does includes pending operations. + """Get the tip. Note that this includes pending operations. Raises: NoTipError: If the tip spot does not have a tip. """ - if self._tip is None: + if self._pending_tip is None: raise NoTipError(f"{self.thing} does not have a tip.") - return self._tip + return self._pending_tip def disable(self) -> None: """Disable the tip tracker.""" From 7b4667798b2520e539897dd05e961330e6a48d7c Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:05:53 -0700 Subject: [PATCH 07/13] feat(hamilton.prep): track tips and volumes on channels and head8 Replace private mounted-tip maps with TipTrackers and call shared finalize after pick/drop and aspirate/dispense so spot, well, and mount state update from command outcomes. --- pylabrobot/hamilton/prep/channels.py | 176 ++++++++++++--- pylabrobot/hamilton/prep/gripper.py | 7 +- pylabrobot/hamilton/prep/head8.py | 208 +++++++++++++++--- .../hamilton/prep/tests/channels_tests.py | 37 +++- pylabrobot/hamilton/prep/tests/head8_tests.py | 27 +++ 5 files changed, 389 insertions(+), 66 deletions(-) diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py index 09677c871c2..7a5e4945dcf 100644 --- a/pylabrobot/hamilton/prep/channels.py +++ b/pylabrobot/hamilton/prep/channels.py @@ -22,6 +22,8 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, + Callable, Generic, List, Literal, @@ -45,7 +47,20 @@ from pylabrobot.resources import Container, Coordinate, Tip from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker from pylabrobot.resources.trash import Trash from pylabrobot.resources.well import CrossSectionType, Well @@ -805,7 +820,7 @@ def __init__( self._supports_v2_pipetting: Optional[bool] = None self.setup_finished: bool = False self.channels: List[PrepPIPChannel] = [] - self._mounted_tips: dict[int, Tip] = {} + self.head: dict[int, TipTracker] = {} 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. @@ -889,10 +904,25 @@ async def _on_setup(self): self._supports_v2_pipetting = True logger.info("V2 aspirate/dispense support: True") + self._ensure_head() self.setup_finished = True async def _on_stop(self): - pass + for tracker in self.head.values(): + tracker.clear() + + def _ensure_head(self) -> None: + """Ensure pipette-side TipTrackers exist for each dual-channel index.""" + for i in range(self.num_channels): + if i not in self.head: + self.head[i] = TipTracker(thing=f"Channel {i}") + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the dual-channel head (``None`` if empty).""" + self._ensure_head() + return [ + self.head[i].get_tip() if self.head[i].has_tip else None for i in range(self.num_channels) + ] async def discover_channel_drives(self) -> ChannelDriveMap: """Re-walk the firmware tree and return a fresh :class:`ChannelDriveMap`. @@ -965,14 +995,41 @@ def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: # --------------------------------------------------------------------------- def _require_mounted_tips(self, use_channels: List[int]) -> List[Tip]: + self._ensure_head() tips: List[Tip] = [] for ch in use_channels: - tip = self._mounted_tips.get(ch) - if tip is None: + tracker = self.head[ch] + if not tracker.has_tip: raise RuntimeError(f"No tip mounted on channel {ch}; call pick_up_tips first.") - tips.append(tip) + tips.append(tracker.get_tip()) return tips + async def _finalize_channel_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + """Send a Prep command and commit/rollback queued tip or volume intents.""" + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error + async def pick_up_tips( self, tip_spots: Sequence[TipSpot], @@ -1057,19 +1114,32 @@ async def pick_up_tips( use_channels=use_channels, ) - await self._client.send_command( - PrepCmd.PrepPickUpTips( - tip_positions=tip_positions, - 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, + self._ensure_head() + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=tip, + channel_tracker=self.head[ch], ) - ) - for ch, tip in zip(use_channels, tips): - self._mounted_tips[ch] = tip + for ch, spot, tip in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.PrepPickUpTips( + tip_positions=tip_positions, + 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, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) async def drop_tips( self, @@ -1146,16 +1216,28 @@ async def drop_tips( ) ) - await self._client.send_command( - PrepCmd.PrepDropTips( - tip_positions=tip_positions, - final_z=resolved_final_z, - seek_speed=seek_speed, - tip_roll_off_distance=roll_off, + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=tip, + channel_tracker=self.head[ch], ) - ) - for ch in use_channels: - self._mounted_tips.pop(ch, None) + for ch, dest, tip in zip(use_channels, destinations, tips) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.PrepDropTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) # --------------------------------------------------------------------------- # V1/V2 aspirate/dispense dispatch helpers @@ -1876,7 +1958,26 @@ async def aspirate( min_z_min = min(k.common.z_minimum for k in kits) lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) - await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next( + k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch] + ), + direction="aspirate", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) + + await self._finalize_channel_command( + use_channels, volume_intents=volume_intents, send=_send + ) async def dispense( self, @@ -1957,7 +2058,26 @@ async def dispense( min_z_min = min(k.common.z_minimum for k in kits) lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) - await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next( + k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch] + ), + direction="dispense", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) + + await self._finalize_channel_command( + use_channels, volume_intents=volume_intents, send=_send + ) def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: """Check if the tip can be picked up by the specified channel. diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py index 8a61946264f..aff5d78b8ab 100644 --- a/pylabrobot/hamilton/prep/gripper.py +++ b/pylabrobot/hamilton/prep/gripper.py @@ -7,6 +7,7 @@ from pylabrobot.resources import Coordinate, Resource from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import place_resource from . import prep_commands as PrepCmd @@ -360,11 +361,7 @@ async def drop_resource( acceleration_scale_x=acceleration_scale_x, ) self._clear_held_state() - held.unassign() - if isinstance(destination, ResourceHolder): - destination.assign_child_resource(held) - else: - destination.assign_child_resource(held, location=Coordinate.zero()) + place_resource(held, destination) async def drop_at_location( self, diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py index 3a67621c9e6..2574019292c 100644 --- a/pylabrobot/hamilton/prep/head8.py +++ b/pylabrobot/hamilton/prep/head8.py @@ -22,15 +22,29 @@ import logging import struct as _struct -from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, Optional, Sequence, Union from pylabrobot.hamilton.liquid_class_resolver import ( corrected_volumes_for_ops, resolve_hamilton_liquid_classes, ) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass from pylabrobot.resources import Container, Coordinate, Tip, Trash +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker from pylabrobot.resources.well import Well from . import prep_commands as PrepCmd @@ -114,7 +128,9 @@ def __init__( self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense self.channels: list = [] # populated by build_prep_channels after construction self._supports_v2_pipetting: Optional[bool] = None - self._mounted_tips: list[Tip] = [] + self.head: dict[int, TipTracker] = { + i: TipTracker(thing=f"Head8 channel {i}") for i in range(NUM_PROBES) + } # --------------------------------------------------------------------------- # Setup / V2 probing @@ -147,7 +163,39 @@ async def _on_setup(self) -> None: async def _on_stop(self) -> None: self._supports_v2_pipetting = None - self._mounted_tips = [] + for tracker in self.head.values(): + tracker.clear() + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the 8MPH (``None`` if empty).""" + return [ + self.head[i].get_tip() if self.head[i].has_tip else None for i in range(NUM_PROBES) + ] + + async def _finalize_head8_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error # --------------------------------------------------------------------------- # Internal helpers @@ -654,10 +702,17 @@ async def move_to_position( # Tip / aspirate / dispense # --------------------------------------------------------------------------- + def _require_mounted_tips(self) -> List[Tip]: + tips: List[Tip] = [] + for i in range(NUM_PROBES): + tracker = self.head[i] + if not tracker.has_tip: + raise RuntimeError("No tips mounted on head8; call pick_up_tips8 first.") + tips.append(tracker.get_tip()) + return tips + def _require_mounted_tip(self) -> Tip: - if not self._mounted_tips: - raise RuntimeError("No tips mounted on head8; call pick_up_tips8 first.") - return self._mounted_tips[0] + return self._require_mounted_tips()[0] async def pick_up_tips8( self, @@ -708,19 +763,32 @@ async def pick_up_tips8( is_needle=False, is_tool=False, ) - await self._client.send_command( - PrepCmd.MphPickupTips( - tip_position=tip_position, - 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=_FULL_TIP_MASK, + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=t, + channel_tracker=self.head[ch], ) - ) - self._mounted_tips = tips + for ch, spot, t in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.MphPickupTips( + tip_position=tip_position, + 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=_FULL_TIP_MASK, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) async def drop_tips8( self, @@ -763,15 +831,29 @@ async def drop_tips8( drop_type=drop_type, ) roll_off = 3.0 if (is_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance - await self._client.send_command( - PrepCmd.MphDropTips( - tip_position=tip_position, - final_z=resolved_final_z, - seek_speed=seek_speed, - tip_roll_off_distance=roll_off, + mounted = self._require_mounted_tips() + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=mounted[ch], + channel_tracker=self.head[ch], ) - ) - self._mounted_tips = [] + for ch, dest in zip(use_channels, destinations) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.MphDropTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) async def aspirate8( self, @@ -952,9 +1034,40 @@ def __init__(self, tip: Tip, volume: float): if resolved_read_timeout is None and effective_lld: resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) - await self._client.send_command( - cmd_cls(aspirate_parameters=[param_struct]), # type: ignore[arg-type] - read_timeout=resolved_read_timeout if effective_lld else None, + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.send_command( + cmd_cls(aspirate_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command( + use_channels, volume_intents=volume_intents, send=_send ) async def dispense8( @@ -1133,9 +1246,40 @@ def __init__(self, tip: Tip, volume: float): if resolved_read_timeout is None and effective_lld: resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) - await self._client.send_command( - cmd_cls(dispense_parameters=[param_struct]), # type: ignore[arg-type] - read_timeout=resolved_read_timeout if effective_lld else None, + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.send_command( + cmd_cls(dispense_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command( + use_channels, volume_intents=volume_intents, send=_send ) # --------------------------------------------------------------------------- diff --git a/pylabrobot/hamilton/prep/tests/channels_tests.py b/pylabrobot/hamilton/prep/tests/channels_tests.py index 65f994e1211..443924d8551 100644 --- a/pylabrobot/hamilton/prep/tests/channels_tests.py +++ b/pylabrobot/hamilton/prep/tests/channels_tests.py @@ -6,7 +6,8 @@ from pylabrobot.hamilton.prep import Prep from pylabrobot.hamilton.prep.channels import PrepChannels, PrepPIPChannel -from pylabrobot.resources.hamilton import STARLetDeck +from pylabrobot.resources.hamilton import PrepDeck, STARLetDeck, hamilton_96_tiprack_50uL_NTR +from pylabrobot.resources.tip_tracker import set_tip_tracking def _run(coro): @@ -44,3 +45,37 @@ async def _t(): await p.stop() _run(_t()) + + +def test_channels_tip_trackers_pick_and_drop(): + """pick_up_tips / drop_tips update spot + channel TipTrackers when tip tracking is on.""" + + async def _t(): + set_tip_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")] + n = min(2, p.channels.num_channels) + spots = spots[:n] + use = list(range(n)) + assert all(s.has_tip() for s in spots) + assert all(t is None for t in p.channels.get_mounted_tips()[:n]) + + await p.channels.pick_up_tips(spots, use_channels=use) + assert all(not s.has_tip() for s in spots) + mounted = p.channels.get_mounted_tips() + assert all(mounted[i] is not None for i in use) + assert all(p.channels.head[i].has_tip for i in use) + + await p.channels.drop_tips(spots, use_channels=use) + assert all(s.has_tip() for s in spots) + assert all(not p.channels.head[i].has_tip for i in use) + await p.stop() + finally: + set_tip_tracking(False) + + _run(_t()) diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py index d90aa37e6e2..d8044df7823 100644 --- a/pylabrobot/hamilton/prep/tests/head8_tests.py +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -175,6 +175,33 @@ async def _run() -> None: asyncio.run(_run()) +def test_head8_tip_trackers_pick_and_drop(): + """8 TipTrackers stay in sync across pick_up_tips8 / drop_tips8 with tip tracking on.""" + from pylabrobot.resources.tip_tracker import set_tip_tracking + + async def _run() -> None: + set_tip_tracking(True) + try: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + spots = tip_rack.column(0) + assert all(s.has_tip() for s in spots) + await p.head8.pick_up_tips8(spots) + assert all(not s.has_tip() for s in spots) + assert all(p.head8.head[i].has_tip for i in range(8)) + assert all(t is not None for t in p.head8.get_mounted_tips()) + await p.head8.drop_tips8(spots) + assert all(s.has_tip() for s in spots) + assert all(not p.head8.head[i].has_tip for i in range(8)) + await p.stop() + finally: + set_tip_tracking(False) + + asyncio.run(_run()) + + def test_mph_move_to_position_command_metadata(): move = PrepCmd.MphMoveToPosition(x_position=1.5, y_position=2.5, z_position=120.0) assert move.firmware_path == "MLPrepRoot.MphRoot.MPH" From c7d7ddfd0ca2809f8e045a25835c17f2b555925c Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:06:03 -0700 Subject: [PATCH 08/13] fix(hamilton.prep): pre-position channels before CoRe tool pickup Move to mount XY at traverse height before PrepPickUpTool, matching tip pickup, so tool engage does not swoop down from an arbitrary pose. --- pylabrobot/hamilton/prep/gripper.py | 16 ++++- .../hamilton/prep/tests/gripper_tests.py | 71 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py index aff5d78b8ab..3703b8fdf4d 100644 --- a/pylabrobot/hamilton/prep/gripper.py +++ b/pylabrobot/hamilton/prep/gripper.py @@ -153,12 +153,26 @@ async def pick_up_tool( tool_x_radius: float = 2.0, tool_y_radius: float = 2.0, tip_definition: Optional[PrepCmd.TipPickupParameters] = None, + pre_position: bool = True, ) -> None: - """Pick up CoRe gripper tool (PrepPickUpTool, cmd=15). Moves channels to safe Z after.""" + """Pick up CoRe gripper tool (PrepPickUpTool, cmd=15). + + When ``pre_position`` is True (default), moves both channels to the tool XY at + traverse height before the firmware pickup (same pattern as tip pickup). + After pickup, moves channels to safe Z. + """ if tool_seek is None: tool_seek = tool_position_z + 10.0 if tip_definition is None: tip_definition = PrepCmd.CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + if pre_position: + traverse_h = self._channels._resolve_traverse_height() + await self._channels.move_to_position( + x=tool_position_x, + y=[rear_channel_position_y, front_channel_position_y], + z=traverse_h, + use_channels=[0, 1], + ) await self._client.send_command( PrepCmd.PrepPickUpTool( tip_definition=tip_definition, diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py index 3c310e8b63e..e0573db500a 100644 --- a/pylabrobot/hamilton/prep/tests/gripper_tests.py +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -8,12 +8,26 @@ import pytest +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm from pylabrobot.resources import Coordinate from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub from pylabrobot.resources.hamilton import PrepDeck +def _record_send(prep: Prep) -> list[Any]: + captured: list[Any] = [] + orig_send = prep.client.send_command + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.send_command = recording # type: ignore[method-assign, assignment] + return captured + + def _make_arm(deck: PrepDeck) -> PrepGripperArm: backend = PrepGripper(client=AsyncMock(), channels=AsyncMock()) backend.pick_up_at_location = AsyncMock() # type: ignore[method-assign] @@ -185,3 +199,60 @@ async def _run() -> None: assert dropped_loc.z == pytest.approx(expected.z) asyncio.run(_run()) + + +def test_pick_up_tool_default_pre_position_moves_then_picks(): + """Default pre_position=True issues PrepMoveToPosition before PrepPickUpTool.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + await p.pick_up_core_grippers() + + seq = [ + c + for c in captured + if isinstance(c, (PrepCmd.PrepMoveToPosition, PrepCmd.PrepPickUpTool)) + ] + assert len(seq) >= 2 + assert isinstance(seq[0], PrepCmd.PrepMoveToPosition) + assert isinstance(seq[1], PrepCmd.PrepPickUpTool) + + await p.return_core_grippers() + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tool_pre_position_false_skips_move(): + """Explicit pre_position=False sends PrepPickUpTool without a prior PrepMoveToPosition.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + mount = deck.get_resource("core_grippers") + loc = mount.get_location_wrt(deck) + await p.gripper.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, + pre_position=False, + ) + + moves = [c for c in captured if isinstance(c, PrepCmd.PrepMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.PrepPickUpTool)] + assert moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) From d1d14df00d49c82fb3c5ded3e730c1e0689302f8 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:07:49 -0700 Subject: [PATCH 09/13] docs(hamilton.prep): enable tip and volume tracking in basic demo Show deck-rooted visualizer updates for tip spots and well fills, with status prints for channel mount state during dual-channel transfer. --- .../hamilton-prep/prep_basic_demo.ipynb | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb new file mode 100644 index 00000000000..df2c57a7d75 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -0,0 +1,389 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0130a732", + "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 (with tip + volume tracking).\n", + "3. **Plate movement** — CoRe gripper: pick plate from deck[4], drop at deck[2].\n", + "\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[4] (moved to deck[2]). Visualizer is rooted on the deck: tip-spot occupancy, well fills, and plate assignment update live when tracking is enabled. Pipette mount state lives on `prep.channels.head` (printed in the transfer section; not yet wired into the visualizer pipette panel).\n", + "\n", + "Uses {class}`~pylabrobot.hamilton.prep.prep.Prep` with `prep.channels` for pipetting and `prep.pick_up_core_grippers()` for CoRe plate moves. Firmware tree / command-signature dumps live in the channel introspection notebooks.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Imports and config\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "530ed0bf", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import sys\n", + "from asyncio import sleep\n", + "\n", + "from pylabrobot.hamilton.prep import Prep\n", + "from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking\n", + "from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub\n", + "from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR\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", + "# Opt-in labware tracking (drives TipSpot / well updates in Visualizer(deck)).\n", + "set_tip_tracking(True)\n", + "set_volume_tracking(True)\n", + "\n", + "HOST = \"192.168.100.102\" # \"127.0.0.1\" For port forwarded connection if set up\n", + "PORT = 2000\n", + "SAFE_HEIGHT_MM_ABOVE_WELL = 20\n" + ] + }, + { + "cell_type": "markdown", + "id": "1c6ddc58", + "metadata": {}, + "source": [ + "## 2. Deck layout and visualizer\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "fb2dbcdd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "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": [ + "# PrepDeck: spots 0–7 (column-major). With CoRe grippers mount 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[4] = cor_axy_96_wellplate_500uL_Ub(\"plate\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()\n" + ] + }, + { + "cell_type": "markdown", + "id": "9df37796", + "metadata": {}, + "source": [ + "## 3. Prep device and setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7c1b3d1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Initializing Hamilton connection...\n", + "INFO - Registering Hamilton client...\n", + "INFO - Discovering Hamilton root objects...\n", + "INFO - Discovering Hamilton global objects...\n", + "INFO - Hamilton TCP client setup complete. Client ID: 6, globals: 1\n", + "INFO - MLPrep already initialized, skipping Initialize\n", + "INFO - Discovered 2 Channel Root channel drive pair(s)\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", + "INFO - Channel bounds: [{'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': 0.0, 'y_max': 385.0, 'z_min': 19.5, 'z_max': 167.5}, {'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': -9.0, 'y_max': 376.0, 'z_min': 19.5, 'z_max': 167.5}]\n", + "INFO - V2 aspirate/dispense support: True\n", + "INFO - Discovered 1 MPH Channel Root channel drive pair(s)\n", + "INFO - MPH V2 aspirate/dispense support: True\n" + ] + } + ], + "source": [ + "prep = Prep(deck=deck, host=HOST, port=PORT)\n", + "await prep.setup(smart=True, force_initialize=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff331c04", + "metadata": {}, + "outputs": [], + "source": [ + "await prep.set_deck_light(255, 0, 0, 0)\n" + ] + }, + { + "cell_type": "markdown", + "id": "f9d0dd3c", + "metadata": {}, + "source": [ + "## 4. Device snapshot\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "72746142", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "channels=2, has_mph=True, traverse_height=167.5, enclosure=True, safe_speeds=False\n" + ] + } + ], + "source": [ + "cfg = prep.info.config\n", + "print(\n", + " f\"channels={cfg.num_channels}, has_mph={cfg.has_mph}, \"\n", + " f\"traverse_height={cfg.default_traverse_height}, \"\n", + " f\"enclosure={cfg.has_enclosure}, safe_speeds={cfg.safe_speeds_enabled}\"\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ec9e02e", + "metadata": {}, + "source": [ + "## 5. Teaching needle: above plate A1 at safe height\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87c8a767", + "metadata": {}, + "outputs": [], + "source": [ + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "assert prep.channels is not None\n", + "teaching_tip = deck.get_resource(\"teaching_tip\")\n", + "if not teaching_tip.has_tip():\n", + " teaching_tip.tracker.add_tip(teaching_tip.make_tip(), origin=teaching_tip, commit=True)\n", + "\n", + "await prep.channels.pick_up_tips([teaching_tip], use_channels=[0])\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 prep.channels.move_to_position(safe_pos.x, safe_pos.y, safe_pos.z, use_channels=[0])\n", + "await sleep(3)\n", + "\n", + "await prep.channels.drop_tips([teaching_tip], use_channels=[0])\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n" + ] + }, + { + "cell_type": "markdown", + "id": "e5f4ee40", + "metadata": {}, + "source": [ + "## 6. Tip pickup, aspirate, dispense (dual channel)\n", + "\n", + "Tip and volume tracking are on: tip spots and well fills update in the visualizer; `prep.channels.head` holds mount identity (printed below).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "86c2325e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [None, None]\n", + "after pick\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [0, 0]\n", + "after aspirate\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [35.0, 25.0]\n", + "after dispense\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [0.0, 0.0]\n", + "after drop\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [None, None]\n" + ] + } + ], + "source": [ + "assert prep.channels is not None\n", + "tip_spots = tip_rack[\"A1:B1\"]\n", + "channels = [0, 1]\n", + "src = plate[\"A1:B1\"]\n", + "dst = plate[\"A7:B7\"]\n", + "vols = [35.0, 25.0]\n", + "\n", + "for well in src:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + "def _status(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots]}\")\n", + " print(f\" mounted tips: {prep.channels.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst]}\")\n", + " tips = [prep.channels.head[ch].get_tip() if prep.channels.head[ch].has_tip else None for ch in channels]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + "_status(\"before pick\")\n", + "await prep.channels.pick_up_tips(tip_spots, use_channels=channels)\n", + "_status(\"after pick\")\n", + "\n", + "await prep.channels.aspirate(\n", + " src,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after aspirate\")\n", + "\n", + "await prep.channels.dispense(\n", + " dst,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after dispense\")\n", + "\n", + "await prep.channels.drop_tips(tip_spots, use_channels=channels)\n", + "_status(\"after drop\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "31fa0172", + "metadata": {}, + "source": [ + "## 7. Plate movement with CoRe gripper (deck[4] → deck[2])\n", + "\n", + "`drop_resource` reassigns the plate in the resource tree; the visualizer moves it to deck[2].\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "11e1622b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "plate parent: spot_0_2\n" + ] + } + ], + "source": [ + "arm = await prep.pick_up_core_grippers()\n", + "await arm.pick_up_resource(plate)\n", + "await arm.drop_resource(deck[2])\n", + "await prep.return_core_grippers()\n", + "print(f\"plate parent: {plate.parent.name if plate.parent is not None else None}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6100111d", + "metadata": {}, + "source": [ + "## 8. Teardown\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c5f5981d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Closing connection to socket 127.0.0.1:2000\n", + "INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "await prep.park()\n", + "#await prep.disco_mode()\n", + "await prep.stop()\n", + "await visualizer.stop()\n" + ] + } + ], + "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 +} From 6ae095ce14e243d8ceab2c526433983d8d74600a Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:17:51 -0700 Subject: [PATCH 10/13] docs(hamilton.prep): add head8 transfer to basic demo and wire Sphinx Capture tip/volume tracking through dual-channel and 8MPH sections, link the notebook under Hamilton Prep docs, add channels volume-tracker coverage, and apply ruff formatting on the related Prep modules. --- docs/conf.py | 8 ++ .../hamilton-prep/prep_basic_demo.ipynb | 132 +++++++++++++++--- docs/user_guide/hamilton/index.md | 1 + docs/user_guide/hamilton/prep/index.md | 7 + pylabrobot/hamilton/prep/channels.py | 35 ++--- pylabrobot/hamilton/prep/head8.py | 48 +++---- .../hamilton/prep/tests/channels_tests.py | 60 ++++++++ .../hamilton/prep/tests/gripper_tests.py | 4 +- pylabrobot/resources/resource_state_tests.py | 16 +-- 9 files changed, 230 insertions(+), 81 deletions(-) create mode 100644 docs/user_guide/hamilton/prep/index.md diff --git a/docs/conf.py b/docs/conf.py index 000239139e3..c1c6799b15e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,6 +66,14 @@ "Thumbs.db", ".DS_Store", "jupyter_execute", + # Local Prep investigation notebooks (not published); keep basic demo in toctree. + "user_guide/00_liquid-handling/hamilton-prep/260629_testing.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_additional_probes_investigation.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo_v0_reference.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_channel_introspection.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_features_demo.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_head8_demo.ipynb", + "user_guide/00_liquid-handling/hamilton-prep/prep_head8_mph_introspection.ipynb", ] autodoc_default_options = { diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb index df2c57a7d75..cb88e97654a 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -9,16 +9,18 @@ "\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 (with tip + volume tracking).\n", - "3. **Plate movement** — CoRe gripper: pick plate from deck[4], drop at deck[2].\n", + "2. **Dual-channel liquid handling** — Tip pickup, aspirate, and dispense (tip + volume tracking).\n", + "3. **8MPH (`head8`) liquid handling** — Full-column tip pickup, aspirate, and dispense when `has_mph`.\n", + "4. **Plate movement** — CoRe gripper: pick plate from deck[4], drop at deck[2].\n", "\n", - "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[4] (moved to deck[2]). Visualizer is rooted on the deck: tip-spot occupancy, well fills, and plate assignment update live when tracking is enabled. Pipette mount state lives on `prep.channels.head` (printed in the transfer section; not yet wired into the visualizer pipette panel).\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[4] (moved to deck[2]). Visualizer is rooted on the deck: tip-spot occupancy, well fills, and plate assignment update live when tracking is enabled. Pipette mount state lives on `prep.channels.head` / `prep.head8.head` (printed below; not yet wired into the visualizer pipette panel).\n", "\n", - "Uses {class}`~pylabrobot.hamilton.prep.prep.Prep` with `prep.channels` for pipetting and `prep.pick_up_core_grippers()` for CoRe plate moves. Firmware tree / command-signature dumps live in the channel introspection notebooks.\n" + "Uses {class}`~pylabrobot.hamilton.prep.prep.Prep` with `prep.channels`, `prep.head8`, and `prep.pick_up_core_grippers()`. Firmware tree / command-signature dumps live in the channel introspection notebooks.\n" ] }, { "cell_type": "markdown", + "id": "9d5a702a", "metadata": {}, "source": [ "## 1. Imports and config\n" @@ -66,7 +68,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 12, "id": "fb2dbcdd", "metadata": {}, "outputs": [ @@ -74,8 +76,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "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" + "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" ] } ], @@ -100,7 +102,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 14, "id": "7c1b3d1c", "metadata": {}, "outputs": [ @@ -112,7 +114,7 @@ "INFO - Registering Hamilton client...\n", "INFO - Discovering Hamilton root objects...\n", "INFO - Discovering Hamilton global objects...\n", - "INFO - Hamilton TCP client setup complete. Client ID: 6, globals: 1\n", + "INFO - Hamilton TCP client setup complete. Client ID: 7, globals: 1\n", "INFO - MLPrep already initialized, skipping Initialize\n", "INFO - Discovered 2 Channel Root channel drive pair(s)\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", @@ -148,7 +150,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 15, "id": "72746142", "metadata": {}, "outputs": [ @@ -215,7 +217,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 16, "id": "86c2325e", "metadata": {}, "outputs": [ @@ -307,16 +309,110 @@ "id": "31fa0172", "metadata": {}, "source": [ - "## 7. Plate movement with CoRe gripper (deck[4] → deck[2])\n", + "## 7. Tip pickup, aspirate, dispense (8MPH / head8)\n", "\n", - "`drop_resource` reassigns the plate in the resource tree; the visualizer moves it to deck[2].\n" + "Skipped when `prep.head8` is None. Uses tip rack `A2:H2` and plate `A2:H2` → `A4:H4` so it does not collide with the dual-channel wells above. All 8 probes operate together.\n" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 17, "id": "11e1622b", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n", + "INFO - [Prep MPH] pick_up_tips: rack=ntr_50, tip_spots=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after pick8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + "INFO - [Prep MPH] aspirate: resource=plate, wells=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2'], volume=15.000, flow_rate=100.0\n", + "after aspirate8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + "INFO - [Prep MPH] dispense: resource=plate, wells=['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4'], volume=15.000, flow_rate=100.0\n", + "after dispense8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]\n", + "INFO - [Prep MPH] drop_tips: dest=ntr_50, resources=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after drop8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n" + ] + } + ], + "source": [ + "if prep.head8 is None:\n", + " print(\"head8 not present (has_mph=False); skipping 8MPH transfer\")\n", + "else:\n", + " tip_spots8 = tip_rack[\"A2:H2\"]\n", + " src8 = plate[\"A2:H2\"]\n", + " dst8 = plate[\"A4:H4\"]\n", + " vol8 = 15.0\n", + " for well in src8:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + " def _status8(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots8]}\")\n", + " print(f\" mounted tips: {prep.head8.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src8]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst8]}\")\n", + " tips = [\n", + " prep.head8.head[i].get_tip() if prep.head8.head[i].has_tip else None\n", + " for i in range(8)\n", + " ]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + " _status8(\"before pick8\")\n", + " await prep.head8.pick_up_tips8(tip_spots8)\n", + " _status8(\"after pick8\")\n", + "\n", + " await prep.head8.aspirate8(wells=src8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after aspirate8\")\n", + "\n", + " await prep.head8.dispense8(wells=dst8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after dispense8\")\n", + "\n", + " await prep.head8.drop_tips8(tip_spots8)\n", + " _status8(\"after drop8\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6100111d", + "metadata": {}, + "source": [ + "## 8. Plate movement with CoRe gripper (deck[4] → deck[2])\n", + "\n", + "`drop_resource` reassigns the plate in the resource tree; the visualizer moves it to deck[2].\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c5f5981d", + "metadata": {}, "outputs": [ { "name": "stdout", @@ -336,16 +432,16 @@ }, { "cell_type": "markdown", - "id": "6100111d", + "id": "6eafd340", "metadata": {}, "source": [ - "## 8. Teardown\n" + "## 9. Teardown\n" ] }, { "cell_type": "code", - "execution_count": 7, - "id": "c5f5981d", + "execution_count": 19, + "id": "907a9ce2", "metadata": {}, "outputs": [ { diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md index b3d980e3e2c..59a1985d6d6 100644 --- a/docs/user_guide/hamilton/index.md +++ b/docs/user_guide/hamilton/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 star/index +prep/index ``` diff --git a/docs/user_guide/hamilton/prep/index.md b/docs/user_guide/hamilton/prep/index.md new file mode 100644 index 00000000000..9868eb077c1 --- /dev/null +++ b/docs/user_guide/hamilton/prep/index.md @@ -0,0 +1,7 @@ +# Prep + +```{toctree} +:maxdepth: 1 + +../../00_liquid-handling/hamilton-prep/prep_basic_demo +``` diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py index 7a5e4945dcf..b8c12c5b2ef 100644 --- a/pylabrobot/hamilton/prep/channels.py +++ b/pylabrobot/hamilton/prep/channels.py @@ -1061,9 +1061,7 @@ async def pick_up_tips( assert max(use_channels) < self.num_channels, ( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) - offsets_list = ( - list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) - ) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) if len(offsets_list) != len(tip_spots): raise ValueError("len(offsets) must equal len(tip_spots)") @@ -1106,7 +1104,9 @@ async def pick_up_tips( if pre_position: traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z - locs = [indexed[ch][0].get_absolute_location("c", "c", "t") + indexed[ch][2] for ch in use_channels] + locs = [ + indexed[ch][0].get_absolute_location("c", "c", "t") + indexed[ch][2] for ch in use_channels + ] await self.move_to_position( x=locs[0].x, y=[loc.y for loc in locs], @@ -1172,9 +1172,7 @@ async def drop_tips( f"use_channels index out of range (valid: 0..{self.num_channels - 1})" ) tips = self._require_mounted_tips(use_channels) - offsets_list = ( - list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) - ) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) if len(offsets_list) != len(destinations): raise ValueError("len(offsets) must equal len(destinations)") @@ -1863,7 +1861,12 @@ def _build_transfers( lhs = list(liquid_height) if liquid_height is not None else [None] * n frs = list(flow_rates) if flow_rates is not None else [None] * n bavs = list(blow_out_air_volume) if blow_out_air_volume is not None else [None] * n - for name, seq in (("offsets", offs), ("liquid_height", lhs), ("flow_rates", frs), ("blow_out_air_volume", bavs)): + for name, seq in ( + ("offsets", offs), + ("liquid_height", lhs), + ("flow_rates", frs), + ("blow_out_air_volume", bavs), + ): if len(seq) != n: raise ValueError(f"{name} length must match use_channels ({n})") return [ @@ -1963,9 +1966,7 @@ async def aspirate( channel=ch, container=op.resource, tip=op.tip, - volume_ul=next( - k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch] - ), + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), direction="aspirate", ) for ch, op in zip(use_channels, ops) @@ -1975,9 +1976,7 @@ async def aspirate( async def _send() -> None: await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) - await self._finalize_channel_command( - use_channels, volume_intents=volume_intents, send=_send - ) + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) async def dispense( self, @@ -2063,9 +2062,7 @@ async def dispense( channel=ch, container=op.resource, tip=op.tip, - volume_ul=next( - k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch] - ), + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), direction="dispense", ) for ch, op in zip(use_channels, ops) @@ -2075,9 +2072,7 @@ async def dispense( async def _send() -> None: await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) - await self._finalize_channel_command( - use_channels, volume_intents=volume_intents, send=_send - ) + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: """Check if the tip can be picked up by the specified channel. diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py index 2574019292c..936b462ed40 100644 --- a/pylabrobot/hamilton/prep/head8.py +++ b/pylabrobot/hamilton/prep/head8.py @@ -168,9 +168,7 @@ async def _on_stop(self) -> None: def get_mounted_tips(self) -> List[Optional[Tip]]: """Tips currently mounted on the 8MPH (``None`` if empty).""" - return [ - self.head[i].get_tip() if self.head[i].has_tip else None for i in range(NUM_PROBES) - ] + return [self.head[i].get_tip() if self.head[i].has_tip else None for i in range(NUM_PROBES)] async def _finalize_head8_command( self, @@ -882,7 +880,9 @@ async def aspirate8( tadm: Optional[PrepCmd.TadmParameters] = None, container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, auto_container_geometry: bool = False, - hamilton_liquid_classes: Optional[Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]]] = None, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, disable_volume_correction: bool = False, read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, @@ -912,15 +912,11 @@ def __init__(self, tip: Tip, volume: float): tip_vol = _TipVol(tip, float(volume)) hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) hlc = hlcs[0] - corrected = corrected_volumes_for_ops( - [tip_vol], hlcs, [disable_volume_correction] - )[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] traverse_z = self._resolve_traverse_height() final_z_resolved = ( - z_final - if z_final is not None - else traverse_z - (tip.total_tip_length - tip.fitting_depth) + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) ) if container is not None: @@ -939,7 +935,9 @@ def __init__(self, tip: Tip, volume: float): if len(wells_list) != NUM_PROBES: raise ValueError(f"aspirate8 requires {NUM_PROBES} wells, got {len(wells_list)}") self._resolve_probe_positions(wells_list) - resource_name = wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = ref_loc.x, ref_loc.y @@ -1066,9 +1064,7 @@ async def _send() -> None: read_timeout=resolved_read_timeout if effective_lld else None, ) - await self._finalize_head8_command( - use_channels, volume_intents=volume_intents, send=_send - ) + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) async def dispense8( self, @@ -1096,7 +1092,9 @@ async def dispense8( c_lld: Optional[PrepCmd.CLldParameters] = None, container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, auto_container_geometry: bool = False, - hamilton_liquid_classes: Optional[Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]]] = None, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, disable_volume_correction: bool = False, read_timeout: Optional[float] = None, command_version: Optional[Literal["v1", "v2"]] = None, @@ -1127,15 +1125,11 @@ def __init__(self, tip: Tip, volume: float): tip_vol = _TipVol(tip, float(volume)) hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) hlc = hlcs[0] - corrected = corrected_volumes_for_ops( - [tip_vol], hlcs, [disable_volume_correction] - )[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] traverse_z = self._resolve_traverse_height() final_z_resolved = ( - z_final - if z_final is not None - else traverse_z - (tip.total_tip_length - tip.fitting_depth) + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) ) if container is not None: @@ -1154,7 +1148,9 @@ def __init__(self, tip: Tip, volume: float): if len(wells_list) != NUM_PROBES: raise ValueError(f"dispense8 requires {NUM_PROBES} wells, got {len(wells_list)}") self._resolve_probe_positions(wells_list) - resource_name = wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") ref_x, ref_y = ref_loc.x, ref_loc.y @@ -1196,9 +1192,7 @@ def __init__(self, tip: Tip, volume: float): else (hlc.dispense_stop_flow_rate if hlc is not None else 100.0) ) resolved_flow = ( - flow_rate - if flow_rate is not None - else (hlc.dispense_flow_rate if hlc is not None else 100.0) + flow_rate if flow_rate is not None else (hlc.dispense_flow_rate if hlc is not None else 100.0) ) logger.info( @@ -1278,9 +1272,7 @@ async def _send() -> None: read_timeout=resolved_read_timeout if effective_lld else None, ) - await self._finalize_head8_command( - use_channels, volume_intents=volume_intents, send=_send - ) + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) # --------------------------------------------------------------------------- # Tip presence sensing diff --git a/pylabrobot/hamilton/prep/tests/channels_tests.py b/pylabrobot/hamilton/prep/tests/channels_tests.py index 443924d8551..f50defe8b86 100644 --- a/pylabrobot/hamilton/prep/tests/channels_tests.py +++ b/pylabrobot/hamilton/prep/tests/channels_tests.py @@ -4,10 +4,14 @@ import asyncio +import pytest + from pylabrobot.hamilton.prep import Prep from pylabrobot.hamilton.prep.channels import PrepChannels, PrepPIPChannel +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub from pylabrobot.resources.hamilton import PrepDeck, STARLetDeck, hamilton_96_tiprack_50uL_NTR from pylabrobot.resources.tip_tracker import set_tip_tracking +from pylabrobot.resources.volume_tracker import set_volume_tracking def _run(coro): @@ -79,3 +83,59 @@ async def _t(): set_tip_tracking(False) _run(_t()) + + +def test_channels_volume_trackers_aspirate_dispense(): + """aspirate/dispense update well and tip VolumeTrackers when volume tracking is on.""" + + async def _t(): + set_tip_tracking(True) + set_volume_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + plate = deck[0] = cor_axy_96_wellplate_500uL_Ub("plate") + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + n = min(2, p.channels.num_channels) + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")][:n] + use = list(range(n)) + src = plate["A1:B1"][:n] + dst = plate["A7:B7"][:n] + vols = [20.0] * n + for well in src: + well.tracker.set_volume(100.0) + + await p.channels.pick_up_tips(spots, use_channels=use) + await p.channels.aspirate( + src, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in src: + assert well.tracker.get_used_volume() == pytest.approx(80.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(20.0) + + await p.channels.dispense( + dst, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in dst: + assert well.tracker.get_used_volume() == pytest.approx(20.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(0.0) + + await p.channels.drop_tips(spots, use_channels=use) + await p.stop() + finally: + set_tip_tracking(False) + set_volume_tracking(False) + + _run(_t()) diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py index e0573db500a..1a55761f0b5 100644 --- a/pylabrobot/hamilton/prep/tests/gripper_tests.py +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -214,9 +214,7 @@ async def _run() -> None: await p.pick_up_core_grippers() seq = [ - c - for c in captured - if isinstance(c, (PrepCmd.PrepMoveToPosition, PrepCmd.PrepPickUpTool)) + c for c in captured if isinstance(c, (PrepCmd.PrepMoveToPosition, PrepCmd.PrepPickUpTool)) ] assert len(seq) >= 2 assert isinstance(seq[0], PrepCmd.PrepMoveToPosition) diff --git a/pylabrobot/resources/resource_state_tests.py b/pylabrobot/resources/resource_state_tests.py index a5eb461549c..e53e7672087 100644 --- a/pylabrobot/resources/resource_state_tests.py +++ b/pylabrobot/resources/resource_state_tests.py @@ -56,9 +56,7 @@ def test_pickup_commit_clears_spot_and_mounts_channel(self) -> None: spot = _spot() channel = TipTracker(thing="ch0") tip = spot.get_tip() - intents = [ - TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel) - ] + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] queue_tip_pickups(intents) finalize_tip_ops(intents, {0: True}) self.assertFalse(spot.has_tip()) @@ -69,9 +67,7 @@ def test_pickup_rollback_restores_spot(self) -> None: spot = _spot() channel = TipTracker(thing="ch0") tip = spot.get_tip() - intents = [ - TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel) - ] + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] queue_tip_pickups(intents) finalize_tip_ops(intents, {0: False}) self.assertTrue(spot.has_tip()) @@ -87,9 +83,7 @@ def test_drop_to_spot_and_trash(self) -> None: queue_tip_pickups(pick) finalize_tip_ops(pick, {0: True}) - drop_spot = [ - TipDropIntent(channel=0, destination=dest, tip=tip, channel_tracker=channel) - ] + drop_spot = [TipDropIntent(channel=0, destination=dest, tip=tip, channel_tracker=channel)] queue_tip_drops(drop_spot) finalize_tip_ops(drop_spot, {0: True}) self.assertTrue(dest.has_tip()) @@ -99,9 +93,7 @@ def test_drop_to_spot_and_trash(self) -> None: pick2 = [TipPickupIntent(channel=0, tip_spot=dest, tip=tip2, channel_tracker=channel)] queue_tip_pickups(pick2) finalize_tip_ops(pick2, {0: True}) - drop_trash = [ - TipDropIntent(channel=0, destination=trash, tip=tip2, channel_tracker=channel) - ] + drop_trash = [TipDropIntent(channel=0, destination=trash, tip=tip2, channel_tracker=channel)] queue_tip_drops(drop_trash) finalize_tip_ops(drop_trash, {0: True}) self.assertFalse(channel.has_tip) From 82a52bad31476473acdfbaa450db4c8645fadd64 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:39:14 -0700 Subject: [PATCH 11/13] fix(hamilton.prep): make PrepCommand.dest Python 3.9 / mypy clean Drop field(kw_only=...) (3.10+), default dest in __post_init__, and declare dest on constructors that pass it. Also fix liquid-class test typing, gripper mount isinstance, and head8 test formatting for CI. --- pylabrobot/hamilton/prep/prep_commands.py | 35 +++++++++++++------ .../hamilton/prep/tests/gripper_tests.py | 3 +- pylabrobot/hamilton/prep/tests/head8_tests.py | 1 + .../tests/liquid_class_resolver_tests.py | 5 +-- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/pylabrobot/hamilton/prep/prep_commands.py b/pylabrobot/hamilton/prep/prep_commands.py index 1ea8e822e87..4725b604fe2 100644 --- a/pylabrobot/hamilton/prep/prep_commands.py +++ b/pylabrobot/hamilton/prep/prep_commands.py @@ -1233,16 +1233,15 @@ class DispenseParametersLld2: class PrepCommand(TCPCommand): """Base for all Prep instrument commands. - Subclasses are dataclasses with optional ``dest: Address`` (kw-only, - defaulted) plus any ``Annotated`` payload fields. ``build_parameters()`` - is inherited from ``TCPCommand`` and serialises only ``Annotated`` fields - via ``HoiParams.from_struct``, so ``dest`` is automatically excluded from - the wire payload. - - Firmware target is declared via the class-level ``firmware_path`` attribute; - ``PrepClient.send_command`` resolves it JIT. Polymorphic-dest commands (e.g. - ``PrepGetPositions`` on MPH vs pipettor) can set ``firmware_path = None`` - and require callers to pass an explicit ``dest=``. + Subclasses are dataclasses with ``Annotated`` payload fields. + ``build_parameters()`` is inherited from ``TCPCommand`` and serialises only + ``Annotated`` fields via ``HoiParams.from_struct``. + + Destination defaults to :data:`_UNRESOLVED` in :meth:`__post_init__` (not a + dataclass field — ``field(kw_only=...)`` needs Python 3.10+ and this package + supports 3.9). Callers that need ``dest=`` at construction declare + ``dest: Address`` (required or defaulted) on that concrete subclass. + ``PrepClient.send_command`` resolves ``_UNRESOLVED`` from ``firmware_path``. """ protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -1254,7 +1253,10 @@ class PrepCommand(TCPCommand): # Aggregates populated by ``__init_subclass__`` at import time (unique paths for chatterbox seeding). _ALL_PATHS: ClassVar[Set[str]] = set() - dest: Address = field(default=_UNRESOLVED, kw_only=True) + # Instance field so dataclasses generate ``__init__`` (shadowing TCPCommand's + # ``__init__(dest)`` for typecheckers). ``init=False`` keeps it out of + # constructors and avoids Python 3.9 field-ordering errors on subclasses. + _prep_command_base: None = field(default=None, init=False, repr=False, compare=False) def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -1264,6 +1266,8 @@ def __init_subclass__(cls, **kwargs): PrepCommand._ALL_PATHS.add(path) def __post_init__(self): + if not hasattr(self, "dest"): + self.dest = _UNRESOLVED super().__init__(self.dest) def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: @@ -1985,6 +1989,7 @@ class PrepGetIsInitialized(PrepStatusRequest): command_id = 2 firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2261,6 +2266,7 @@ class PrepGetIsEnclosurePresent(PrepStatusRequest): command_id = 21 firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2273,6 +2279,7 @@ class PrepGetSafeSpeedsEnabled(PrepStatusRequest): command_id = 28 firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2285,6 +2292,7 @@ class PrepGetDefaultTraverseHeight(PrepStatusRequest): command_id = 10 firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2302,6 +2310,7 @@ class PrepGetTipAndNeedleDefinitions(PrepStatusRequest): command_id = 11 firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2314,6 +2323,7 @@ class PrepGetDeckBounds(PrepStatusRequest): command_id = 1 firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2352,6 +2362,7 @@ class PrepGetDeckSiteDefinitions(PrepStatusRequest): command_id = 7 firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2369,6 +2380,7 @@ class PrepGetWasteSiteDefinitions(PrepStatusRequest): command_id = 12 firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: @@ -2402,6 +2414,7 @@ class PrepGetPresentChannels(PrepStatusRequest): command_id = 17 firmware_path = "MLPrepRoot.MLPrepService" + dest: Address = _UNRESOLVED @dataclass(frozen=True) class Response: diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py index 1a55761f0b5..9adc5047ab3 100644 --- a/pylabrobot/hamilton/prep/tests/gripper_tests.py +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -13,7 +13,7 @@ from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm from pylabrobot.resources import Coordinate from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub -from pylabrobot.resources.hamilton import PrepDeck +from pylabrobot.resources.hamilton import HamiltonCoreGrippers, PrepDeck def _record_send(prep: Prep) -> list[Any]: @@ -237,6 +237,7 @@ async def _run() -> None: captured = _record_send(p) mount = deck.get_resource("core_grippers") + assert isinstance(mount, HamiltonCoreGrippers) loc = mount.get_location_wrt(deck) await p.gripper.pick_up_tool( tool_position_x=loc.x, diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py index d8044df7823..650c3bd269c 100644 --- a/pylabrobot/hamilton/prep/tests/head8_tests.py +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -30,6 +30,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_deck(): deck = PrepDeck() tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) diff --git a/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py index e11298f3c77..6c6adb72e57 100644 --- a/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py +++ b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py @@ -3,6 +3,7 @@ from __future__ import annotations from types import SimpleNamespace +from typing import Any, Dict import pytest @@ -16,8 +17,8 @@ from pylabrobot.resources.liquid import Liquid -def _hlc(**overrides: float) -> HamiltonLiquidClass: - base = dict( +def _hlc(**overrides: Any) -> HamiltonLiquidClass: + base: Dict[str, Any] = dict( curve={0.0: 0.0, 1000.0: 1000.0}, aspiration_flow_rate=1.0, aspiration_mix_flow_rate=2.0, From 04adcbef11130c99f4ab4f707b23676d0b78bf8d Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:41:44 -0700 Subject: [PATCH 12/13] style(resources): isort resource_state re-exports in __init__ --- pylabrobot/resources/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index 2ee7d95ac20..449326eceb0 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -39,6 +39,7 @@ from .porvair import * from .powder import Powder from .resource import Resource +from .resource_stack import ResourceStack from .resource_state import ( TipDropIntent, TipPickupIntent, @@ -52,7 +53,6 @@ queue_volume_transfers, successes_from_failed_channels, ) -from .resource_stack import ResourceStack from .revvity import * from .rotation import Rotation from .sergi import * From 24a0c51759819c5f1e3dca02998bff62e86e728f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:59:43 -0700 Subject: [PATCH 13/13] docs(hamilton.prep): drop local notebook excludes from Sphinx conf Those investigation notebooks are not in the PR; listing them in conf.py leaked local-only paths into shared docs config. Demo wiring stays via hamilton/prep/index.md. --- docs/conf.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c1c6799b15e..000239139e3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,14 +66,6 @@ "Thumbs.db", ".DS_Store", "jupyter_execute", - # Local Prep investigation notebooks (not published); keep basic demo in toctree. - "user_guide/00_liquid-handling/hamilton-prep/260629_testing.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_additional_probes_investigation.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo_v0_reference.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_channel_introspection.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_features_demo.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_head8_demo.ipynb", - "user_guide/00_liquid-handling/hamilton-prep/prep_head8_mph_introspection.ipynb", ] autodoc_default_options = {