From 03c1668b50bc8b18114f3f3d6d8022ab2f691d69 Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 02:46:40 +0200 Subject: [PATCH 1/7] feat(labcyte): add Labcyte Echo 525 support The Echo 525 speaks the identical Medman SOAP-over-HTTP protocol as the Echo 650 (POST /Medman, gzip-compressed SOAP envelopes, same RPC method set and DoWellTransfer layout), verified against a Wireshark capture of a 525 (Model "Echo 525", software 2.7.3) running a HiFi PCR protocol. The only functional difference is transfer volume granularity: the 525 dispenses in 25 nL increments (GetTransferVolIncrNl/MinimumNl == 25) vs the 650's 2.5 nL. Rather than fork the 4.3k-line driver, this: - parameterizes the volume increment in echo.py (backward-compatible defaults preserve all Echo 650 behavior) and adds a driver-factory hook to Echo so subclasses can inject model-specific drivers; - adds echo525.py with Echo525 / Echo525Driver overriding only the 525 defaults (25 nL increment, observed 2.6/2.7.3 protocol/client versions); - adds echo525_tests.py (10 tests) pinned to the captured device traffic, including a byte-exact reproduction of the captured 384-well transfer. Existing Echo 650 test suite (78 tests) still passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/labcyte/__init__.py | 5 + pylabrobot/labcyte/echo.py | 42 +++++-- pylabrobot/labcyte/echo525.py | 107 ++++++++++++++++++ pylabrobot/labcyte/echo525_tests.py | 168 ++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 pylabrobot/labcyte/echo525.py create mode 100644 pylabrobot/labcyte/echo525_tests.py diff --git a/pylabrobot/labcyte/__init__.py b/pylabrobot/labcyte/__init__.py index 3641d92aa20..afca55f2ff9 100644 --- a/pylabrobot/labcyte/__init__.py +++ b/pylabrobot/labcyte/__init__.py @@ -37,3 +37,8 @@ build_echo_transfer_plan, create_plate_from_echo_info, ) +from .echo525 import ( + ECHO_525_TRANSFER_VOLUME_INCREMENT_NL, + Echo525, + Echo525Driver, +) diff --git a/pylabrobot/labcyte/echo.py b/pylabrobot/labcyte/echo.py index 4a73750890c..0d91b398a49 100644 --- a/pylabrobot/labcyte/echo.py +++ b/pylabrobot/labcyte/echo.py @@ -55,6 +55,11 @@ DEFAULT_ECHO_CONFIGURATION_QUERY = ( '' ) +# Default droplet/transfer volume granularity for the Echo 650 (nL). The Echo 525 dispenses +# in coarser 25 nL increments instead; rather than fork this module, the 525 backend in +# ``echo525.py`` reuses ``EchoDriver``/``Echo`` and overrides this via the +# ``transfer_volume_increment_nl`` constructor argument (threaded through +# ``build_echo_transfer_plan`` -> ``_validate_transfer_volume_nl``). See ``Echo525``. ECHO_TRANSFER_VOLUME_INCREMENT_NL = 2.5 OperatorPause = Callable[[str], Union[None, Awaitable[None]]] @@ -744,15 +749,17 @@ def _normalize_volume_nl(volume: float, volume_unit: str) -> float: raise ValueError("volume_unit must be 'nL' or 'uL'.") -def _validate_transfer_volume_nl(volume_nl: float, context: str = "") -> None: +def _validate_transfer_volume_nl( + volume_nl: float, + context: str = "", + increment_nl: float = ECHO_TRANSFER_VOLUME_INCREMENT_NL, +) -> None: prefix = f"{context}: " if context else "" if volume_nl <= 0: raise ValueError(f"{prefix}volume must be positive, got {volume_nl} nL.") - units = volume_nl / ECHO_TRANSFER_VOLUME_INCREMENT_NL + units = volume_nl / increment_nl if abs(units - round(units)) > 1e-9: - raise ValueError( - f"{prefix}volume {volume_nl} nL is not a multiple of {ECHO_TRANSFER_VOLUME_INCREMENT_NL} nL." - ) + raise ValueError(f"{prefix}volume {volume_nl} nL is not a multiple of {increment_nl} nL.") def _format_transfer_volume_nl(volume_nl: float) -> str: @@ -1125,8 +1132,13 @@ def build_echo_transfer_plan( destination_plate_type: Optional[str] = None, protocol_name: str = "transfer", volume_unit: str = "nL", + volume_increment_nl: float = ECHO_TRANSFER_VOLUME_INCREMENT_NL, ) -> EchoTransferPlan: - """Build Echo protocol XML and source plate map from PLR plates and wells.""" + """Build Echo protocol XML and source plate map from PLR plates and wells. + + ``volume_increment_nl`` is the device's droplet granularity used to validate every + requested volume (2.5 nL for the Echo 650, 25 nL for the Echo 525). + """ planned_transfers: list[EchoPlannedTransfer] = [] protocol_transfers: list[tuple[str, str, float]] = [] @@ -1153,6 +1165,7 @@ def build_echo_transfer_plan( _validate_transfer_volume_nl( planned.volume_nl, f"{source_identifier}->{destination_identifier}", + increment_nl=volume_increment_nl, ) planned_transfers.append(planned) protocol_transfers.append((source_identifier, destination_identifier, planned.volume_nl)) @@ -1763,6 +1776,7 @@ def __init__( token_slot_b: int = DEFAULT_SLOT_B, client_version: str = "3.1.0", protocol_version: str = "3.1", + transfer_volume_increment_nl: float = ECHO_TRANSFER_VOLUME_INCREMENT_NL, ): super().__init__() self.host = host @@ -1776,6 +1790,7 @@ def __init__( self.token_slot_b = token_slot_b self.client_version = client_version self.protocol_version = protocol_version + self.transfer_volume_increment_nl = transfer_volume_increment_nl self._rpc_lock = asyncio.Lock() self._lock_held = False @@ -1850,6 +1865,7 @@ def serialize(self) -> dict: "token_slot_b": self.token_slot_b, "client_version": self.client_version, "protocol_version": self.protocol_version, + "transfer_volume_increment_nl": self.transfer_volume_increment_nl, } async def read_events( @@ -2791,6 +2807,7 @@ def build_transfer_plan( destination_plate_type=destination_plate_type, protocol_name=protocol_name, volume_unit=volume_unit, + volume_increment_nl=self.transfer_volume_increment_nl, ) async def transfer_wells( @@ -3484,6 +3501,13 @@ def plate(self, plate: Optional[Plate]) -> None: class Echo(Device): """Labcyte Echo access-control device frontend.""" + #: Driver class used to talk to the instrument. Subclasses (e.g. the Echo 525) override + #: this to supply model-specific defaults such as the transfer volume increment. + driver_class: type[EchoDriver] = EchoDriver + + #: Human-readable model name used for the deck resource. + model_name: str = "Labcyte Echo" + def __init__( self, host: str, @@ -3493,8 +3517,9 @@ def __init__( app_name: str = "PyLabRobot Echo", owner: Optional[str] = None, token: Optional[str] = None, + **driver_kwargs: Any, ): - driver = EchoDriver( + driver = self.driver_class( host=host, rpc_port=rpc_port, event_port=event_port, @@ -3502,6 +3527,7 @@ def __init__( app_name=app_name, owner=owner, token=token, + **driver_kwargs, ) super().__init__(driver=driver) self.driver: EchoDriver = driver @@ -3513,7 +3539,7 @@ def __init__( size_y=300.0, size_z=260.0, category="labcyte_echo", - model="Labcyte Echo", + model=self.model_name, ) self.source_position = EchoPlatePosition(name="echo_source_position", role="source") self.destination_position = EchoPlatePosition( diff --git a/pylabrobot/labcyte/echo525.py b/pylabrobot/labcyte/echo525.py new file mode 100644 index 00000000000..d4aafb3ac31 --- /dev/null +++ b/pylabrobot/labcyte/echo525.py @@ -0,0 +1,107 @@ +"""Labcyte Echo 525 acoustic liquid handler support. + +The Echo 525 speaks the exact same Medman SOAP-over-HTTP protocol as the Echo 650 +(``POST /Medman``, gzip-compressed SOAP envelopes, identical RPC method names). This +module therefore reuses :class:`~pylabrobot.labcyte.echo.EchoDriver` and +:class:`~pylabrobot.labcyte.echo.Echo` wholesale and only overrides the handful of +device-specific defaults that differ on the 525. + +The defaults below were reverse-engineered from a Wireshark capture of an Echo 525 +(``Model`` reported as ``Echo 525``, software ``2.7.3``) running a HiFi PCR protocol. +Key differences from the Echo 650: + +* **Transfer volume granularity is 25 nL** (the Echo 650 dispenses in 2.5 nL droplets). + The instrument confirmed this over the wire: ``GetTransferVolIncrNl`` and + ``GetTransferVolMinimumNl`` both returned ``25``. Requested volumes must be whole + multiples of 25 nL. +* The captured instrument advertised ``Protocol: 2.6`` / ``Client: 2.7.3`` in its HTTP + headers, so those are the default version strings here (the 650 backend defaults to the + newer 3.1 protocol). Override ``protocol_version`` / ``client_version`` if your 525 runs + newer Echo software. + +Everything else - the access-control RPCs, plate survey, ``DoWellTransfer`` ```` +protocol XML, gripper retract/present, dry-plate, sessions and locking - is inherited +unchanged from the Echo 650 implementation. +""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.labcyte.echo import ( + DEFAULT_EVENT_PORT, + DEFAULT_RPC_PORT, + DEFAULT_SLOT_A, + DEFAULT_SLOT_B, + DEFAULT_TIMEOUT, + Echo, + EchoDriver, +) + +#: Droplet / transfer volume increment of the Echo 525 (nL). Confirmed by the device's +#: ``GetTransferVolIncrNl`` and ``GetTransferVolMinimumNl`` responses. +ECHO_525_TRANSFER_VOLUME_INCREMENT_NL = 25.0 + +#: Version strings observed in the Echo 525's Medman HTTP headers. +ECHO_525_CLIENT_VERSION = "2.7.3" +ECHO_525_PROTOCOL_VERSION = "2.6" + +#: ``Model`` string the Echo 525 reports from ``GetInstrumentInfo``. +ECHO_525_MODEL_NAME = "Echo 525" + + +class Echo525Driver(EchoDriver): + """Driver for the Labcyte Echo 525. + + Identical to :class:`~pylabrobot.labcyte.echo.EchoDriver` except for 525-specific + defaults (25 nL volume increment and the 2.6/2.7.3 protocol/client versions observed + on real hardware). All keyword arguments remain overridable. + """ + + def __init__( + self, + host: str, + rpc_port: int = DEFAULT_RPC_PORT, + event_port: int = DEFAULT_EVENT_PORT, + timeout: float = DEFAULT_TIMEOUT, + app_name: str = "PyLabRobot Echo 525", + owner: Optional[str] = None, + token: Optional[str] = None, + token_slot_a: int = DEFAULT_SLOT_A, + token_slot_b: int = DEFAULT_SLOT_B, + client_version: str = ECHO_525_CLIENT_VERSION, + protocol_version: str = ECHO_525_PROTOCOL_VERSION, + transfer_volume_increment_nl: float = ECHO_525_TRANSFER_VOLUME_INCREMENT_NL, + ): + super().__init__( + host=host, + rpc_port=rpc_port, + event_port=event_port, + timeout=timeout, + app_name=app_name, + owner=owner, + token=token, + token_slot_a=token_slot_a, + token_slot_b=token_slot_b, + client_version=client_version, + protocol_version=protocol_version, + transfer_volume_increment_nl=transfer_volume_increment_nl, + ) + + +class Echo525(Echo): + """Labcyte Echo 525 device frontend. + + Drop-in replacement for :class:`~pylabrobot.labcyte.echo.Echo` that wires up the + :class:`Echo525Driver` (25 nL transfer increment, 525 protocol defaults). + + Example: + >>> echo = Echo525(host="192.168.0.25") + >>> await echo.setup() + >>> async with echo.plate_access: # lock + safe door handling + ... await echo.driver.transfer([(source_well, dest_well, 150)]) # 150 nL, a multiple of 25 + """ + + driver_class = Echo525Driver + model_name = ECHO_525_MODEL_NAME + driver: Echo525Driver diff --git a/pylabrobot/labcyte/echo525_tests.py b/pylabrobot/labcyte/echo525_tests.py new file mode 100644 index 00000000000..06e64d78512 --- /dev/null +++ b/pylabrobot/labcyte/echo525_tests.py @@ -0,0 +1,168 @@ +# mypy: disable-error-code="arg-type,func-returns-value,method-assign,union-attr" +"""Tests for the Labcyte Echo 525 backend. + +The golden values below are taken from a real Wireshark capture of an Echo 525 +(``Model`` = ``Echo 525``, software ``2.7.3``) executing a HiFi PCR protocol, so these +tests pin the backend to traffic the physical instrument actually produced and accepted. +""" + +import gzip +import unittest +from unittest.mock import patch + +from pylabrobot.labcyte.echo import build_echo_transfer_plan +from pylabrobot.labcyte.echo525 import ( + ECHO_525_TRANSFER_VOLUME_INCREMENT_NL, + Echo525, + Echo525Driver, +) +from pylabrobot.labcyte.echo_tests import ( + _FakeReader, + _FakeWriter, + _make_plate, + _soap_response, +) + + +class TestEcho525Defaults(unittest.IsolatedAsyncioTestCase): + """Pin the device-specific defaults observed on the wire.""" + + def test_driver_uses_captured_525_defaults(self): + driver = Echo525Driver(host="192.168.0.25") + # GetTransferVolIncrNl / GetTransferVolMinimumNl both returned 25 on the device. + self.assertEqual(driver.transfer_volume_increment_nl, 25.0) + self.assertEqual(ECHO_525_TRANSFER_VOLUME_INCREMENT_NL, 25.0) + # Versions advertised in the instrument's Medman HTTP headers. + self.assertEqual(driver.protocol_version, "2.6") + self.assertEqual(driver.client_version, "2.7.3") + + def test_device_wires_up_525_driver(self): + echo = Echo525(host="192.168.0.25") + self.assertIsInstance(echo.driver, Echo525Driver) + self.assertEqual(echo.driver.transfer_volume_increment_nl, 25.0) + self.assertEqual(echo.deck.model, "Echo 525") + + def test_token_matches_captured_host_header_shape(self): + # Captured Host header: 192.168.0.25:47500:33224:1780941641:10347 + token = Echo525Driver.build_token("192.168.0.25", slot_a=47500, slot_b=33224, + epoch=1780941641, pid=10347) + self.assertEqual(token, "192.168.0.25:47500:33224:1780941641:10347") + fields = token.split(":") + self.assertEqual(len(fields), 5) + self.assertEqual(fields[0], "192.168.0.25") + self.assertTrue(all(f.isdigit() for f in fields[1:])) + + +class TestEcho525VolumeGranularity(unittest.IsolatedAsyncioTestCase): + """The 525 dispenses in 25 nL increments; the 650 does 2.5 nL.""" + + def setUp(self): + self.driver = Echo525Driver(host="192.168.0.25") + self.source = _make_plate("source", "6RES_AQ_BP2") + self.destination = _make_plate("destination", "384PP_AQ_BP2") + + def test_multiple_of_25nl_is_accepted(self): + plan = self.driver.build_transfer_plan( + self.source, self.destination, [("A1", "B1", 150)] + ) + self.assertIn('', plan.protocol_xml) + + def test_non_multiple_of_25nl_is_rejected(self): + # 10 nL is a legal 650 volume (multiple of 2.5) but illegal on the 525. + with self.assertRaises(ValueError) as ctx: + self.driver.build_transfer_plan(self.source, self.destination, [("A1", "B1", 10)]) + self.assertIn("multiple of 25", str(ctx.exception)) + + def test_25nl_minimum_droplet_is_accepted(self): + plan = self.driver.build_transfer_plan( + self.source, self.destination, [("A1", "B1", 25)] + ) + self.assertIn('v="25"', plan.protocol_xml) + + def test_650_increment_still_rejected_on_525(self): + # 2.5 nL is the smallest 650 droplet and must NOT be allowed on a 525. + with self.assertRaises(ValueError): + self.driver.build_transfer_plan(self.source, self.destination, [("A1", "B1", 2.5)]) + + +class TestEcho525WireFormat(unittest.IsolatedAsyncioTestCase): + """Confirm the bytes we put on the wire match what the Echo 525 expects.""" + + async def test_rpc_request_matches_captured_medman_framing(self): + driver = Echo525Driver(host="192.168.0.25", timeout=1.0) + await driver.setup() + + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + 'True' + 'OK' + 'Echo 525' + 'E5XX-00000' + "" + ) + ) + + async def fake_open_connection(host: str, port: int): + self.assertEqual(host, "192.168.0.25") + self.assertEqual(port, 8000) + return fake_reader, fake_writer + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + side_effect=fake_open_connection, + ): + info = await driver.get_instrument_info() + + request = bytes(fake_writer.buffer) + head, _, body = request.partition(b"\r\n\r\n") + head_text = head.decode("iso-8859-1") + + # Request line + Medman framing exactly as the real Echo client used. + self.assertTrue(head_text.startswith("POST /Medman HTTP/1.1")) + self.assertIn("Protocol: 2.6", head_text) + self.assertIn("Client: 2.7.3", head_text) + self.assertIn('SOAPAction: "Some-URI"', head_text) + self.assertIn("Host: 192.168.0.25:", head_text) # token-shaped host header + + # Body is gzip-compressed SOAP (the 525 only accepts gzipped Medman bodies). + decompressed = gzip.decompress(body).decode("utf-8") + self.assertIn(" layout the 525 would emit. + driver = Echo525Driver(host="192.168.0.25") + source = _make_plate("source", "6RES_AQ_BP2") + destination = _make_plate("destination", "384PP_AQ_BP2") + transfers = [("A2", dn, 150) for dn in ("A1", "B1", "A3")] + + plan = driver.build_transfer_plan(source, destination, transfers, protocol_name="hifi_pcr") + + self.assertIn('', plan.protocol_xml) + self.assertIn('', plan.protocol_xml) + self.assertIn('', plan.protocol_xml) + # All transfers draw from a single source well -> sparse, de-duplicated plate map. + self.assertEqual(plan.plate_map.well_identifiers, ("A2",)) + + +class TestEcho650RegressionGuard(unittest.IsolatedAsyncioTestCase): + """The 525 changes must not alter Echo 650 (2.5 nL) behaviour.""" + + def test_650_default_still_accepts_2_5nl(self): + source = _make_plate("source", "384PP_DMSO2") + destination = _make_plate("destination", "1536LDV_Dest") + # No volume_increment_nl override -> 650 default of 2.5 nL. + plan = build_echo_transfer_plan(source, destination, [("A1", "B1", 2.5)]) + self.assertIn('', plan.protocol_xml) + + +if __name__ == "__main__": + unittest.main() From 68c1f18e46812816a0330a2046f373f502d1dfe2 Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 03:05:34 +0200 Subject: [PATCH 2/7] test(labcyte): add EchoMockServer for hardware-free Echo 525 testing Adds an in-process asyncio mock of the Echo Medman protocol (POST /Medman + gzip SOAP), replaying real responses captured from the physical Echo 525, so Echo/Echo525 can be driven end-to-end with no instrument attached. Mirrors the MicroSpinMockServer pattern from #1047. - Replays 33 captured device responses keyed by SOAP method; the 384-well DoWellTransfer print-map is trimmed to 3 wells to keep the fixture ~3.5 KB. - Models the instrument lock so motion/transfer RPCs issued without the lock get the Echo's real "Caller does not own the lock" fault. - 4 new end-to-end tests: captured 525 identity (Model "Echo 525"), 25 nL increment read over the wire, full lock->transfer->unlock flow, lock gating. 14 Echo 525 tests pass; Echo 650 suite (78) still green. --- pylabrobot/labcyte/__init__.py | 1 + pylabrobot/labcyte/echo525_tests.py | 49 ++++++ pylabrobot/labcyte/echo_mock.py | 225 ++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 pylabrobot/labcyte/echo_mock.py diff --git a/pylabrobot/labcyte/__init__.py b/pylabrobot/labcyte/__init__.py index afca55f2ff9..53aaefc53f5 100644 --- a/pylabrobot/labcyte/__init__.py +++ b/pylabrobot/labcyte/__init__.py @@ -42,3 +42,4 @@ Echo525, Echo525Driver, ) +from .echo_mock import EchoMockServer diff --git a/pylabrobot/labcyte/echo525_tests.py b/pylabrobot/labcyte/echo525_tests.py index 06e64d78512..c39ca6aca22 100644 --- a/pylabrobot/labcyte/echo525_tests.py +++ b/pylabrobot/labcyte/echo525_tests.py @@ -16,6 +16,7 @@ Echo525, Echo525Driver, ) +from pylabrobot.labcyte.echo_mock import EchoMockServer from pylabrobot.labcyte.echo_tests import ( _FakeReader, _FakeWriter, @@ -164,5 +165,53 @@ def test_650_default_still_accepts_2_5nl(self): self.assertIn('', plan.protocol_xml) +class TestEcho525AgainstMockServer(unittest.IsolatedAsyncioTestCase): + """End-to-end runs against EchoMockServer, which replays real captured 525 responses.""" + + async def test_get_instrument_info_returns_captured_525_identity(self): + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port, timeout=5.0) + await echo.setup() + info = await echo.get_instrument_info() + self.assertEqual(info.model, "Echo 525") + self.assertEqual(info.serial_number, "E5XX-00000") + self.assertEqual(info.software_version, "2.7.3") + + async def test_device_reports_25nl_increment_over_the_wire(self): + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port, timeout=5.0) + await echo.setup() + increment = await echo.driver.get_transfer_volume_increment_nl("6RES_AQ_BP2") + self.assertEqual(increment, 25) + + async def test_full_lock_transfer_unlock_flow(self): + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port, timeout=5.0) + await echo.setup() + await echo.driver.lock() + xml = ( + '' + '' + ) + result = await echo.driver.do_well_transfer(xml) + await echo.driver.unlock() + self.assertTrue(result.succeeded) + self.assertEqual(result.status, "OK") + self.assertGreater(len(result.transfers), 0) # replayed (trimmed) print-map report + self.assertEqual( + [m for m, _ in srv.received], + ["LockInstrument", "DoWellTransfer", "UnlockInstrument"], + ) + + async def test_mock_gates_motion_commands_on_the_lock(self): + # A real Echo rejects motion/transfer unless the caller holds the lock; the mock models it. + async with EchoMockServer() as srv: + before = srv.response_for("DoWellTransfer") + srv._locked = True + after = srv.response_for("DoWellTransfer") + self.assertIn("does not own the lock", before) + self.assertNotIn("does not own the lock", after) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/labcyte/echo_mock.py b/pylabrobot/labcyte/echo_mock.py new file mode 100644 index 00000000000..063b0a32311 --- /dev/null +++ b/pylabrobot/labcyte/echo_mock.py @@ -0,0 +1,225 @@ +"""In-process mock of the Labcyte Echo Medman protocol for hardware-free testing. + +``EchoMockServer`` is a localhost ``asyncio`` TCP server that speaks the same wire protocol +as a real Echo (``POST /Medman`` HTTP requests with gzip-compressed SOAP bodies) and replays +**real device responses** captured from a physical Echo 525 (serial ``E5XX-00000``, software +``2.7.3``) running a HiFi PCR protocol. It lets you drive ``Echo``/``Echo525`` end-to-end with +no instrument attached: + + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port) + await echo.setup() + info = await echo.get_instrument_info() # -> Model "Echo 525" + +The server dispatches by SOAP method name, replaying the captured response for that method +(33 methods captured) and falling back to a generic ``SUCCEEDED/OK`` envelope otherwise. It +also models the instrument lock: motion/transfer RPCs issued without holding the lock get the +Echo's real "Caller does not own the lock" fault, so the locking workflow can be exercised +deterministically (mirrors how a real Echo gates these commands). + +The captured responses are stored gzip-compressed and base64-encoded below; the bulky 384-well +``DoWellTransfer`` print-map report is trimmed to 3 representative wells to keep the fixture small. +""" + +from __future__ import annotations + +import asyncio +import base64 +import gzip +import json +import re +from types import TracebackType +from typing import Dict, Optional, Tuple, Type + +# Methods that require the caller to hold the instrument lock on a real Echo. +_LOCK_REQUIRED = frozenset({ + "DoWellTransfer", "PlateSurvey", "DryPlate", "CloseDoor", "OpenDoor", "HomeAxes", + "PresentSrcPlateGripper", "PresentDstPlateGripper", + "RetractSrcPlateGripper", "RetractDstPlateGripper", +}) + +_REQUEST_METHOD = re.compile(rb"]*><([A-Za-z][A-Za-z0-9_]*)") +_HEADER_END = b"\r\n\r\n" + +# gzip(json({method: captured_response_soap_envelope})) from a real Echo 525 capture. +_CAPTURED_RESPONSES_B64 = ( + "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" + "4HOK+PY+OGy8/+//S3Oqr07EAAA=" +) + + +def _load_captured_responses() -> Dict[str, str]: + blob = base64.b64decode("".join(_CAPTURED_RESPONSES_B64)) + return json.loads(gzip.decompress(blob).decode("utf-8")) + + +def _generic_response(method: str, *, succeeded: bool = True, status: str = "OK") -> str: + return ( + '' + '' + '' + f"<{method}Response><{method}>" + f'{succeeded}' + f'{status}' + f"" + "" + ) + + +class EchoMockServer: + """A localhost server that emulates the Echo Medman RPC protocol.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 0): + self._host = host + self._requested_port = port + self._server: Optional[asyncio.AbstractServer] = None + self._responses = _load_captured_responses() + self._locked = False + #: every (method, was_locked) the server handled - handy for assertions in tests. + self.received: list[Tuple[str, bool]] = [] + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + if self._server is None: + raise RuntimeError("EchoMockServer is not running; use 'async with EchoMockServer()'.") + return self._server.sockets[0].getsockname()[1] + + async def __aenter__(self) -> "EchoMockServer": + await self.start() + return self + + async def __aexit__(self, exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], tb: Optional[TracebackType]) -> None: + await self.stop() + + async def start(self) -> None: + self._server = await asyncio.start_server(self._handle, self._host, self._requested_port) + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + def response_for(self, method: str) -> str: + """Return the SOAP envelope the mock will reply with for ``method`` (lock-aware).""" + if method in _LOCK_REQUIRED and not self._locked: + return _generic_response(method, succeeded=False, status="Caller does not own the lock") + return self._responses.get(method, _generic_response(method)) + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while True: + message = await self._read_request(reader) + if message is None: + break + method = message + # Update lock state from the *request* (it is granted/released regardless of caller). + if method == "LockInstrument": + self._locked = True + self.received.append((method, self._locked)) + body = gzip.compress(self.response_for(method).encode("utf-8")) + if method == "UnlockInstrument": + self._locked = False + header = ( + "HTTP/1.1 200 OK\r\n" + "Server: Echo. Liquid Handler-2.7.3\r\n" + "Protocol: 2.6\r\n" + 'Content-Type: text/xml; charset="utf-8"\r\n' + f"Content-Length: {len(body)}\r\n\r\n" + ).encode("ascii") + writer.write(header + body) + await writer.drain() + except (asyncio.IncompleteReadError, ConnectionResetError): + pass + finally: + writer.close() + + async def _read_request(self, reader: asyncio.StreamReader) -> Optional[str]: + """Read one HTTP request; return its SOAP method name (or None at EOF).""" + data = b"" + while _HEADER_END not in data: + chunk = await reader.read(4096) + if not chunk: + return None + data += chunk + head, _, rest = data.partition(_HEADER_END) + content_length = 0 + for line in head.split(b"\n"): + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":", 1)[1].strip()) + while len(rest) < content_length: + chunk = await reader.read(content_length - len(rest)) + if not chunk: + break + rest += chunk + try: + decoded = gzip.decompress(rest[:content_length]) + except (OSError, EOFError): + return None + match = _REQUEST_METHOD.search(decoded) + return match.group(1).decode("ascii") if match else "Unknown" From 34ef74ae17ab71e227b399a084e8e4af68118bd4 Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 04:31:17 +0200 Subject: [PATCH 3/7] docs(labcyte): document the Echo 525 - new user-guide page docs/user_guide/labcyte/echo-525.md covering the 25 nL increment, the Echo525 frontend, the EchoMockServer hardware-free workflow, and live-validation override - link it from the Labcyte index toctree and add a note on the Echo 650 page - add Echo525 / Echo525Driver / EchoMockServer to the labcyte API reference The documented mock-server example runs verbatim against EchoMockServer. --- docs/api/pylabrobot.labcyte.rst | 25 +++++++ docs/user_guide/labcyte/echo-525.md | 105 ++++++++++++++++++++++++++++ docs/user_guide/labcyte/echo.md | 6 ++ docs/user_guide/labcyte/index.md | 1 + 4 files changed, 137 insertions(+) create mode 100644 docs/user_guide/labcyte/echo-525.md diff --git a/docs/api/pylabrobot.labcyte.rst b/docs/api/pylabrobot.labcyte.rst index 42987790a86..e7666dd2b88 100644 --- a/docs/api/pylabrobot.labcyte.rst +++ b/docs/api/pylabrobot.labcyte.rst @@ -27,3 +27,28 @@ Echo EchoError EchoProtocolError EchoCommandError + +Echo 525 +-------- + +.. currentmodule:: pylabrobot.labcyte.echo525 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Echo525 + Echo525Driver + +Testing +------- + +.. currentmodule:: pylabrobot.labcyte.echo_mock + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EchoMockServer diff --git a/docs/user_guide/labcyte/echo-525.md b/docs/user_guide/labcyte/echo-525.md new file mode 100644 index 00000000000..8313db9eb21 --- /dev/null +++ b/docs/user_guide/labcyte/echo-525.md @@ -0,0 +1,105 @@ +# Echo 525 + +The Labcyte **Echo 525** speaks the exact same Medman protocol as the [Echo 650](echo): the same +`POST /Medman` transport, gzip-compressed SOAP bodies, RPC method set, lock/session model, plate +survey, and `DoWellTransfer` `` layout. The `Echo525` frontend therefore reuses the entire +Echo 650 implementation and overrides only the handful of defaults that differ on the 525. + +Everything on the [Echo](echo) page — access cycles, surveys, transfers, loading/ejecting, +calibration, plate definitions — applies unchanged. This page covers only the 525-specific parts. + +## What differs from the Echo 650 + +The one behavioural difference is **transfer volume granularity**: the Echo 525 dispenses in +**25 nL** increments, where the Echo 650 uses 2.5 nL. The instrument confirms this over the wire — +`GetTransferVolIncrNl` and `GetTransferVolMinimumNl` both return `25`. Requested volumes must be a +whole multiple of 25 nL; anything else is rejected during transfer planning: + +```python +from pylabrobot.labcyte import Echo525 + +echo = Echo525(host="192.168.0.25") +# 150 nL is fine (6 x 25 nL); 10 nL would raise ValueError on a 525. +``` + +The defaults below were reverse-engineered from a Wireshark capture of a physical Echo 525 +(`Model` = `Echo 525`, software `2.7.3`) running a HiFi PCR reformat, so they reflect real device +traffic rather than assumptions. + +| Default | Echo 525 | Echo 650 | +|---------|----------|----------| +| `transfer_volume_increment_nl` | `25.0` | `2.5` | +| `protocol_version` | `2.6` | `3.1` | +| `client_version` | `2.7.3` | `3.1.0` | +| reported `Model` | `Echo 525` | `Echo 650` | + +All of these remain constructor-overridable for newer firmware. + +## Running a transfer + +`Echo525` is a drop-in replacement for `Echo`; the transfer API is identical: + +```python +import asyncio + +from pylabrobot.labcyte import Echo525 + +async def main(source_plate, destination_plate): + async with Echo525(host="192.168.0.25") as echo: + await echo.lock() + try: + result = await echo.transfer( + [(source_plate.get_well("A1"), destination_plate.get_well("B1"), 150)], # nL, multiple of 25 + source_plate_type="6RES_AQ_BP2", + destination_plate_type="384PP_AQ_BP2", + ) + print(len(result.transfers), len(result.skipped)) + finally: + await echo.unlock() +``` + +## Hardware-free testing with the mock server + +`EchoMockServer` is an in-process `asyncio` server that emulates the Echo Medman protocol and +replays real responses captured from a physical Echo 525. It lets you exercise the full +`Echo`/`Echo525` stack — setup, lock, survey, `DoWellTransfer`, unlock — with no instrument +attached: + +```python +import asyncio + +from pylabrobot.labcyte import Echo525, EchoMockServer + +async def main(): + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port) + await echo.setup() + + info = await echo.get_instrument_info() + assert info.model == "Echo 525" + + await echo.driver.lock() + report = await echo.driver.do_well_transfer( + '' + '' + ) + await echo.driver.unlock() + print(report.succeeded, len(report.transfers)) + +asyncio.run(main()) +``` + +The mock dispatches by SOAP method name, replays the captured response for that method, and models +the instrument lock — motion and transfer RPCs issued without holding the lock get the Echo's real +`Caller does not own the lock` fault, so the locking workflow can be tested deterministically. + +## Live validation + +The opt-in live tests from the [Echo](echo) page work against a 525 as well; set the expected model +so the identity check passes: + +```bash +PYLABROBOT_ECHO_HOST=192.168.0.25 \ +PYLABROBOT_ECHO_EXPECTED_MODEL="Echo 525" \ + uv run --extra dev pytest pylabrobot/labcyte/echo_live_tests.py +``` diff --git a/docs/user_guide/labcyte/echo.md b/docs/user_guide/labcyte/echo.md index 7e89ba36847..5e9c11f4465 100644 --- a/docs/user_guide/labcyte/echo.md +++ b/docs/user_guide/labcyte/echo.md @@ -4,6 +4,12 @@ The Labcyte Echo integration currently targets the Medman surface validated agai It covers safe mechanical access operations, source-plate survey workflows, PLR-native transfer planning, and raw Echo protocol execution through `DoWellTransfer`. +```{note} +Using an **Echo 525**? It speaks the same Medman protocol as the 650 and reuses everything on this +page. The only behavioural difference is its coarser 25 nL transfer increment. See +[Echo 525](echo-525) for the `Echo525` frontend and the hardware-free mock server. +``` + Supported operations: - fetch instrument identity with `get_instrument_info()` diff --git a/docs/user_guide/labcyte/index.md b/docs/user_guide/labcyte/index.md index d4483549570..27aadf380a8 100644 --- a/docs/user_guide/labcyte/index.md +++ b/docs/user_guide/labcyte/index.md @@ -4,5 +4,6 @@ :maxdepth: 1 echo +echo-525 workcell-integration ``` From b8f1f3bc70fb8e572fc4bbe5a8b643032f221571 Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 04:37:32 +0200 Subject: [PATCH 4/7] docs(labcyte): docstrings for EchoMockServer public API Document host/port/start/stop so the mock server's public surface is fully covered (matches the response_for docstring already present). --- pylabrobot/labcyte/echo_mock.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pylabrobot/labcyte/echo_mock.py b/pylabrobot/labcyte/echo_mock.py index 063b0a32311..a564034d398 100644 --- a/pylabrobot/labcyte/echo_mock.py +++ b/pylabrobot/labcyte/echo_mock.py @@ -140,10 +140,12 @@ def __init__(self, host: str = "127.0.0.1", port: int = 0): @property def host(self) -> str: + """The host the mock is listening on; pass to ``Echo``/``Echo525`` as ``host``.""" return self._host @property def port(self) -> int: + """The (possibly OS-assigned) port the mock is listening on; pass as ``rpc_port``.""" if self._server is None: raise RuntimeError("EchoMockServer is not running; use 'async with EchoMockServer()'.") return self._server.sockets[0].getsockname()[1] @@ -157,9 +159,11 @@ async def __aexit__(self, exc_type: Optional[Type[BaseException]], await self.stop() async def start(self) -> None: + """Begin listening for Medman connections (called automatically by ``async with``).""" self._server = await asyncio.start_server(self._handle, self._host, self._requested_port) async def stop(self) -> None: + """Stop the server and close the listening socket.""" if self._server is not None: self._server.close() await self._server.wait_closed() From c6f7f978731367020c84462c39f6bd48143a7d83 Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 04:58:57 +0200 Subject: [PATCH 5/7] test(labcyte): validate the full survey/dry/transfer path against captured responses Addresses the open "document live validation for the expanded survey/transfer path" item from #1001 in a hardware-free, reproducible way: drives survey_source_plate (SetPlateMap -> PlateSurvey -> GetSurveyData -> DryPlate) followed by DoWellTransfer against EchoMockServer, which replays responses captured from a physical Echo 525 running that exact path. --- pylabrobot/labcyte/echo525_tests.py | 40 ++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/pylabrobot/labcyte/echo525_tests.py b/pylabrobot/labcyte/echo525_tests.py index c39ca6aca22..cb10e121b99 100644 --- a/pylabrobot/labcyte/echo525_tests.py +++ b/pylabrobot/labcyte/echo525_tests.py @@ -10,7 +10,12 @@ import unittest from unittest.mock import patch -from pylabrobot.labcyte.echo import build_echo_transfer_plan +from pylabrobot.labcyte.echo import ( + EchoDryPlateMode, + EchoPlateMap, + EchoSurveyParams, + build_echo_transfer_plan, +) from pylabrobot.labcyte.echo525 import ( ECHO_525_TRANSFER_VOLUME_INCREMENT_NL, Echo525, @@ -203,6 +208,39 @@ async def test_full_lock_transfer_unlock_flow(self): ["LockInstrument", "DoWellTransfer", "UnlockInstrument"], ) + async def test_expanded_survey_dry_and_transfer_path(self): + # Exercises the full "expanded survey/transfer path" against responses captured from a real + # 525: SetPlateMap -> PlateSurvey -> GetSurveyData -> DryPlate -> DoWellTransfer. This is the + # hardware-free validation of that path (the device's PlateSurvey only acks; survey results + # come back via GetSurveyData, exactly as the mock replays). + async with EchoMockServer() as srv: + echo = Echo525(host=srv.host, rpc_port=srv.port, timeout=5.0) + await echo.setup() + await echo.driver.lock() + run = await echo.driver.survey_source_plate( + EchoPlateMap(plate_type="6RES_AQ_BP2", well_identifiers=("A2",)), + EchoSurveyParams( + plate_type="6RES_AQ_BP2", start_row=0, start_col=0, num_rows=1, num_cols=1, save=True + ), + dry_after=True, + ) + result = await echo.driver.do_well_transfer( + '' + '' + ) + await echo.driver.unlock() + self.assertIsNotNone(run.saved_data) + self.assertGreater(len(run.saved_data.wells), 0) # real survey well from the capture + self.assertEqual(run.dry_mode, EchoDryPlateMode.TWO_PASS) + self.assertTrue(result.succeeded) + self.assertEqual( + [m for m, _ in srv.received], + [ + "LockInstrument", "SetPlateMap", "PlateSurvey", "GetSurveyData", "DryPlate", + "DoWellTransfer", "UnlockInstrument", + ], + ) + async def test_mock_gates_motion_commands_on_the_lock(self): # A real Echo rejects motion/transfer unless the caller holds the lock; the mock models it. async with EchoMockServer() as srv: From 6518fb9ce33f0cf15000df25df3ebd81a5ee58ae Mon Sep 17 00:00:00 2001 From: c-reiter Date: Wed, 24 Jun 2026 18:39:14 +0200 Subject: [PATCH 6/7] feat(labcyte): SDK-free picklist execution + pluggable protocol generator Adds an open-source, vendor-SDK-free path to run an Echo picklist end-to-end: - picklist.py: Transfer model, read_picklist() for the Echo cherry-pick CSV (a public data format), an EchoProtocolGenerator interface, and NaiveEchoProtocolGenerator (group by source plate type, emit in picklist order, pass through DestX/YOffset). Ordering is deliberately NOT optimised - the vendor's head-travel/serpentine path-finding is proprietary and is left to an out-of-tree generator that implements the same interface. - Echo.run_picklist(): parse -> generate -> survey -> DoWellTransfer, no GUI. Plate loading/access is left to the caller (robotic arm) via PlateAccess. The generator seam lets SDK owners swap in a vendor-exact generator without any vendor code entering this repo. 5 new tests; full labcyte suite 98 green. --- pylabrobot/labcyte/__init__.py | 8 ++ pylabrobot/labcyte/echo.py | 61 ++++++++++ pylabrobot/labcyte/picklist.py | 166 +++++++++++++++++++++++++++ pylabrobot/labcyte/picklist_tests.py | 78 +++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 pylabrobot/labcyte/picklist.py create mode 100644 pylabrobot/labcyte/picklist_tests.py diff --git a/pylabrobot/labcyte/__init__.py b/pylabrobot/labcyte/__init__.py index 53aaefc53f5..0c84204e85d 100644 --- a/pylabrobot/labcyte/__init__.py +++ b/pylabrobot/labcyte/__init__.py @@ -43,3 +43,11 @@ Echo525Driver, ) from .echo_mock import EchoMockServer +from .picklist import ( + EchoProtocolGenerator, + GeneratedTransfer, + NaiveEchoProtocolGenerator, + Transfer, + picklist_from_rows, + read_picklist, +) diff --git a/pylabrobot/labcyte/echo.py b/pylabrobot/labcyte/echo.py index 0d91b398a49..a162d3cd046 100644 --- a/pylabrobot/labcyte/echo.py +++ b/pylabrobot/labcyte/echo.py @@ -4098,6 +4098,67 @@ async def dry_plate(self, params: Optional[EchoDryPlateParams] = None) -> None: """Run ``DryPlate`` with the requested mode.""" await self.driver.dry_plate(params) + @need_setup_finished + async def run_picklist( + self, + picklist: Union[str, Sequence["Transfer"]], + *, + generator: Optional["EchoProtocolGenerator"] = None, + survey: bool = True, + close_door: bool = True, + dry_after: bool = False, + acquire_lock: bool = True, + timeout: Optional[float] = None, + ) -> list[EchoTransferResult]: + """Execute a picklist on the Echo with no GUI: parse -> generate -> survey -> transfer. + + ``picklist`` is a path to an Echo cherry-pick CSV (or a list of :class:`Transfer`). The + ``generator`` turns it into ``DoWellTransfer`` payloads; it defaults to the SDK-free + :class:`~pylabrobot.labcyte.picklist.NaiveEchoProtocolGenerator`. Owners of the vendor SDK + can pass a generator that reproduces the Echo Cherry Pick optimisation exactly. + + Plate loading/ejection and door/gripper access are left to the caller (e.g. a robotic arm via + the :class:`~pylabrobot.capabilities.plate_access.PlateAccess` capability); this method assumes + the source and destination plates are already in place. + """ + from pylabrobot.labcyte.picklist import NaiveEchoProtocolGenerator, read_picklist + + transfers = read_picklist(picklist) if isinstance(picklist, str) else list(picklist) + if not transfers: + raise ValueError("Picklist contained no transfers.") + plan = (generator or NaiveEchoProtocolGenerator()).generate(transfers) + + results: list[EchoTransferResult] = [] + if acquire_lock: + await self.driver.lock() + try: + if close_door: + await self.driver.close_door() + for group in plan: + if survey: + source_wells = tuple(dict.fromkeys(t.source_well for t in group.transfers)) + await self.driver.set_plate_map( + EchoPlateMap(plate_type=group.source_plate_type, well_identifiers=source_wells) + ) + info = await self.driver.get_echo_plate_info(group.source_plate_type) + await self.driver.survey_plate( + EchoSurveyParams( + plate_type=group.source_plate_type, + start_row=0, + start_col=0, + num_rows=info.rows, + num_cols=info.columns, + save=True, + ) + ) + results.append(await self.driver.do_well_transfer(group.protocol_xml, timeout=timeout)) + if dry_after: + await self.driver.dry_plate() + finally: + if acquire_lock: + await self.driver.unlock() + return results + @need_setup_finished async def survey_source_plate( self, diff --git a/pylabrobot/labcyte/picklist.py b/pylabrobot/labcyte/picklist.py new file mode 100644 index 00000000000..b893105fd49 --- /dev/null +++ b/pylabrobot/labcyte/picklist.py @@ -0,0 +1,166 @@ +"""Open-source picklist handling and Echo protocol generation (no vendor SDK required). + +This module is intentionally free of any Labcyte/Beckman SDK code. It contains only: + +* a parser for the **Echo cherry-pick picklist CSV** — a documented, public data format; and +* a *pluggable* protocol generator that turns transfers into the ``DoWellTransfer`` ```` + layout of the (reverse-engineered, interoperability) Medman wire protocol. + +The built-in :class:`NaiveEchoProtocolGenerator` emits transfers **in picklist order** and only +groups them by source plate type (a protocol requirement — one ```` per +``DoWellTransfer``). It deliberately does **not** reimplement the Echo Cherry Pick software's +proprietary transfer-order / head-travel optimisation or survey path-finding. Ordering does not +affect correctness — the instrument dispenses the same droplets regardless — only head-travel +efficiency. Users who own the vendor SDK can supply their own :class:`EchoProtocolGenerator` +(e.g. one that calls the SDK's ``PreProcessProtocol``) via the same interface. +""" + +from __future__ import annotations + +import csv +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Tuple + +# Canonical picklist column names -> Transfer fields. Accepts the common header variants the +# Echo applications export (full names and the barcode-style headers). +_COLUMN_ALIASES: Dict[str, str] = { + "source plate name": "source_plate_name", + "source plate barcode": "source_plate_name", + "source plate type": "source_plate_type", + "source well": "source_well", + "destination plate name": "dest_plate_name", + "destination plate barcode": "dest_plate_name", + "destination plate type": "dest_plate_type", + "destination well": "dest_well", + "volume": "volume_nl", + "transfer volume": "volume_nl", + "destination well x offset": "dest_x_offset_um", + "destination well y offset": "dest_y_offset_um", + "destxoffset": "dest_x_offset_um", + "destyoffset": "dest_y_offset_um", + "sample id": "sample_id", + "sample name": "sample_name", +} + + +@dataclass +class Transfer: + """One source-well -> destination-well transfer from a picklist.""" + + source_well: str + dest_well: str + volume_nl: float + source_plate_type: str = "" + source_plate_name: str = "" + dest_plate_name: str = "" + dest_plate_type: str = "" + dest_x_offset_um: int = 0 + dest_y_offset_um: int = 0 + sample_id: str = "" + sample_name: str = "" + + +def _rc(well: str) -> Tuple[int, int]: + """'A2' -> (row=0, col=1), 0-based. Standard plate well addressing.""" + letters = "".join(c for c in well if c.isalpha()).upper() + digits = "".join(c for c in well if c.isdigit()) + row = 0 + for c in letters: # supports AA.. for >26 rows + row = row * 26 + (ord(c) - 64) + return row - 1, int(digits) - 1 + + +def read_picklist(path: str) -> List[Transfer]: + """Parse an Echo cherry-pick picklist CSV into :class:`Transfer` objects (in file order).""" + with open(path, newline="", encoding="utf-8-sig") as handle: + rows = list(csv.DictReader(handle)) + return picklist_from_rows(rows) + + +def picklist_from_rows(rows: Iterable[Dict[str, str]]) -> List[Transfer]: + transfers: List[Transfer] = [] + for raw in rows: + mapped: Dict[str, str] = {} + for key, value in raw.items(): + if key is None: + continue + field_name = _COLUMN_ALIASES.get(key.strip().lower()) + if field_name is not None and value is not None: + mapped[field_name] = value.strip() + if "source_well" not in mapped or "dest_well" not in mapped: + continue + transfers.append( + Transfer( + source_well=mapped["source_well"], + dest_well=mapped["dest_well"], + volume_nl=float(mapped.get("volume_nl", "0") or 0), + source_plate_type=mapped.get("source_plate_type", ""), + source_plate_name=mapped.get("source_plate_name", ""), + dest_plate_name=mapped.get("dest_plate_name", ""), + dest_plate_type=mapped.get("dest_plate_type", ""), + dest_x_offset_um=int(float(mapped.get("dest_x_offset_um", "0") or 0)), + dest_y_offset_um=int(float(mapped.get("dest_y_offset_um", "0") or 0)), + sample_id=mapped.get("sample_id", ""), + sample_name=mapped.get("sample_name", ""), + ) + ) + return transfers + + +@dataclass +class GeneratedTransfer: + """One ``DoWellTransfer`` worth of work: a source plate type and its protocol XML.""" + + source_plate_type: str + protocol_xml: str + transfers: List[Transfer] = field(default_factory=list) + + +class EchoProtocolGenerator(ABC): + """Turns a flat list of transfers into one or more ``DoWellTransfer`` protocol payloads. + + Implement this to plug in a different generation strategy (e.g. a vendor-SDK-backed generator + that reproduces the Echo Cherry Pick optimisation exactly). The default + :class:`NaiveEchoProtocolGenerator` is SDK-free and unoptimised. + """ + + @abstractmethod + def generate(self, transfers: List[Transfer]) -> List[GeneratedTransfer]: + ... + + +def _wp(oid: int, t: Transfer) -> str: + sr, sc = _rc(t.source_well) + dr, dc = _rc(t.dest_well) + vol = int(t.volume_nl) if float(t.volume_nl).is_integer() else f"{t.volume_nl:g}" + return ( + f'' + ) + + +class NaiveEchoProtocolGenerator(EchoProtocolGenerator): + """SDK-free generator: group by source plate type, emit ```` in **picklist order**. + + Correct but not head-travel optimised — the vendor's transfer-ordering / survey path-finding is + intentionally not reproduced here. Per-transfer XY offsets from the picklist are passed through. + """ + + def __init__(self, protocol_name: str = "pylabrobot"): + self.protocol_name = protocol_name + + def generate(self, transfers: List[Transfer]) -> List[GeneratedTransfer]: + groups: Dict[str, List[Transfer]] = {} + for t in transfers: + groups.setdefault(t.source_plate_type, []).append(t) # first-appearance order preserved + out: List[GeneratedTransfer] = [] + for source_type, group in groups.items(): + wps = "".join(_wp(i, t) for i, t in enumerate(group, 1)) + xml = ( + f'' + f"{source_type}{wps}" + ) + out.append(GeneratedTransfer(source_plate_type=source_type, protocol_xml=xml, transfers=group)) + return out diff --git a/pylabrobot/labcyte/picklist_tests.py b/pylabrobot/labcyte/picklist_tests.py new file mode 100644 index 00000000000..dd61edde9d5 --- /dev/null +++ b/pylabrobot/labcyte/picklist_tests.py @@ -0,0 +1,78 @@ +# mypy: disable-error-code="arg-type" +"""Tests for the SDK-free picklist parsing and protocol generation.""" + +import unittest + +from pylabrobot.labcyte.echo525 import Echo525 +from pylabrobot.labcyte.echo_mock import EchoMockServer +from pylabrobot.labcyte.picklist import ( + NaiveEchoProtocolGenerator, + Transfer, + picklist_from_rows, +) + + +class TestPicklistParsing(unittest.TestCase): + def test_reads_standard_echo_cherry_pick_columns(self): + rows = [ + {"Source Plate Name": "6res", "Source Well": "A2", "Destination Plate Name": "dst", + "Destination Well": "A1", "Volume": "150", "Source Plate Type": "6RES_AQ_BP2"}, + ] + [t] = picklist_from_rows(rows) + self.assertEqual((t.source_well, t.dest_well, t.volume_nl), ("A2", "A1", 150.0)) + self.assertEqual(t.source_plate_type, "6RES_AQ_BP2") + + def test_reads_barcode_header_variant_with_offsets(self): + rows = [ + {"Source Plate Barcode": "P1", "Source Well": "D5", "Destination Plate Barcode": "D1", + "Destination Well": "A1", "Transfer Volume": "25", "DestXOffset": "100", + "DestYOffset": "-50"}, + ] + [t] = picklist_from_rows(rows) + self.assertEqual(t.source_plate_name, "P1") + self.assertEqual((t.dest_x_offset_um, t.dest_y_offset_um), (100, -50)) + + +class TestNaiveGenerator(unittest.TestCase): + def test_groups_by_source_type_and_keeps_picklist_order(self): + transfers = [ + Transfer("A2", "A1", 150, "6RES_AQ_BP2"), + Transfer("B1", "A1", 800, "6RES_AQ_GPSB2"), + Transfer("A2", "A2", 150, "6RES_AQ_BP2"), + ] + plan = NaiveEchoProtocolGenerator().generate(transfers) + # one DoWellTransfer per source plate type + self.assertEqual([g.source_plate_type for g in plan], ["6RES_AQ_BP2", "6RES_AQ_GPSB2"]) + bp2 = plan[0].protocol_xml + self.assertIn("6RES_AQ_BP2", bp2) + # picklist order preserved (A1 then A2) - no reordering + self.assertLess(bp2.index('dn="A1"'), bp2.index('dn="A2"')) + self.assertIn(' Date: Thu, 2 Jul 2026 15:24:24 +0200 Subject: [PATCH 7/7] style(labcyte): satisfy CI (ruff format/lint, mypy, imports) - ruff format + import sorting across the labcyte files - TYPE_CHECKING import so run_picklist's Transfer/EchoProtocolGenerator annotations resolve (F821) - rename a loop var shadowing dataclasses.field (F402); drop a duplicate 'fluids' annotation (mypy no-redef) - echo_mock.py mypy: typed _load_captured_responses result, type _server as asyncio.Server, int() the port ruff, mypy (labcyte), typos and the labcyte tests all pass. --- pylabrobot/labcyte/__init__.py | 18 +++++++------- pylabrobot/labcyte/echo.py | 16 ++++++------ pylabrobot/labcyte/echo525_tests.py | 22 ++++++++-------- pylabrobot/labcyte/echo_live_tests.py | 1 - pylabrobot/labcyte/echo_mock.py | 36 +++++++++++++++++++-------- pylabrobot/labcyte/echo_tests.py | 8 +++--- pylabrobot/labcyte/picklist.py | 7 +++--- pylabrobot/labcyte/picklist_tests.py | 22 ++++++++++++---- 8 files changed, 80 insertions(+), 50 deletions(-) diff --git a/pylabrobot/labcyte/__init__.py b/pylabrobot/labcyte/__init__.py index 0c84204e85d..65247248d47 100644 --- a/pylabrobot/labcyte/__init__.py +++ b/pylabrobot/labcyte/__init__.py @@ -1,15 +1,15 @@ from .echo import ( Echo, EchoCommandError, + EchoDriver, EchoDryPlateMode, EchoDryPlateParams, - EchoDriver, + EchoError, EchoEvent, EchoEventStream, + EchoFluidInfo, EchoFocalSweepParams, EchoFocusState, - EchoError, - EchoFluidInfo, EchoInstrumentInfo, EchoPlannedTransfer, EchoPlateAccessBackend, @@ -17,23 +17,23 @@ EchoPlateInfo, EchoPlateMap, EchoPlatePosition, + EchoPlateWorkflowResult, EchoPowerCalibration, EchoPowerCalibrationResult, - EchoScanPositions, - EchoScannerCalibrationResult, - EchoPlateWorkflowResult, EchoProtocolError, EchoResolvedPlateType, + EchoScannerCalibrationResult, + EchoScanPositions, + EchoSkippedWell, EchoSurveyData, EchoSurveyParams, EchoSurveyRunResult, EchoSurveyWell, - EchoSkippedWell, - EchoTransferPlan, EchoTransferInput, + EchoTransferPlan, EchoTransferPrintOptions, - EchoTransferResult, EchoTransferredWell, + EchoTransferResult, build_echo_transfer_plan, create_plate_from_echo_info, ) diff --git a/pylabrobot/labcyte/echo.py b/pylabrobot/labcyte/echo.py index a162d3cd046..abc29bcbddc 100644 --- a/pylabrobot/labcyte/echo.py +++ b/pylabrobot/labcyte/echo.py @@ -19,6 +19,7 @@ Awaitable, Callable, Dict, + TYPE_CHECKING, Iterable, Optional, Sequence, @@ -26,6 +27,9 @@ Union, ) +if TYPE_CHECKING: + from pylabrobot.labcyte.picklist import EchoProtocolGenerator, Transfer + from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_access import PlateAccess, PlateAccessBackend, PlateAccessState from pylabrobot.device import Device, Driver, need_setup_finished @@ -924,7 +928,7 @@ def _fluid_infos_from_values(values: Dict[str, Any]) -> list[EchoFluidInfo]: fc_mins = _value_list(_value_by_any_key(values, "FCMin")) fc_maxes = _value_list(_value_by_any_key(values, "FCMax")) fc_units = _value_list(_value_by_any_key(values, "FCUnits")) - fluids: list[EchoFluidInfo] = [] + fluids = [] for index, name in enumerate(names): fluid_name = str(name) fluids.append( @@ -1019,11 +1023,11 @@ def _plate_type_ex_xml(plate_type: str, values: Dict[str, Any]) -> str: root = ET.Element("PlateTypeEx", {soap_encoding_style: encoding}) normalized = _plate_type_ex_values_from_rpc_values(values) normalized["Name"] = plate_type - for field, value_type in _PLATE_TYPE_EX_FIELD_TYPES: - value = _plate_type_ex_value(normalized, field, value_type) + for attr, value_type in _PLATE_TYPE_EX_FIELD_TYPES: + value = _plate_type_ex_value(normalized, attr, value_type) child = ET.SubElement( root, - field, + attr, { soap_encoding_style: encoding, "type": f"xsd:{value_type}", @@ -2170,9 +2174,7 @@ async def get_all_plate_inserts(self) -> list[str]: async def get_coupling_fluid_sound_velocity(self) -> Optional[float]: result = await self._rpc("GetCouplingFluidSoundVelocity") self._ensure_success("GetCouplingFluidSoundVelocity", result) - return _float_or_none( - _value_by_any_key(result.values, "CouplingFluidSoundVelocity", "Value") - ) + return _float_or_none(_value_by_any_key(result.values, "CouplingFluidSoundVelocity", "Value")) async def get_focus_tof(self) -> Optional[float]: result = await self._rpc("GetTOFFocus") diff --git a/pylabrobot/labcyte/echo525_tests.py b/pylabrobot/labcyte/echo525_tests.py index cb10e121b99..df3c7a8c3b2 100644 --- a/pylabrobot/labcyte/echo525_tests.py +++ b/pylabrobot/labcyte/echo525_tests.py @@ -50,8 +50,9 @@ def test_device_wires_up_525_driver(self): def test_token_matches_captured_host_header_shape(self): # Captured Host header: 192.168.0.25:47500:33224:1780941641:10347 - token = Echo525Driver.build_token("192.168.0.25", slot_a=47500, slot_b=33224, - epoch=1780941641, pid=10347) + token = Echo525Driver.build_token( + "192.168.0.25", slot_a=47500, slot_b=33224, epoch=1780941641, pid=10347 + ) self.assertEqual(token, "192.168.0.25:47500:33224:1780941641:10347") fields = token.split(":") self.assertEqual(len(fields), 5) @@ -68,9 +69,7 @@ def setUp(self): self.destination = _make_plate("destination", "384PP_AQ_BP2") def test_multiple_of_25nl_is_accepted(self): - plan = self.driver.build_transfer_plan( - self.source, self.destination, [("A1", "B1", 150)] - ) + plan = self.driver.build_transfer_plan(self.source, self.destination, [("A1", "B1", 150)]) self.assertIn('', plan.protocol_xml) def test_non_multiple_of_25nl_is_rejected(self): @@ -80,9 +79,7 @@ def test_non_multiple_of_25nl_is_rejected(self): self.assertIn("multiple of 25", str(ctx.exception)) def test_25nl_minimum_droplet_is_accepted(self): - plan = self.driver.build_transfer_plan( - self.source, self.destination, [("A1", "B1", 25)] - ) + plan = self.driver.build_transfer_plan(self.source, self.destination, [("A1", "B1", 25)]) self.assertIn('v="25"', plan.protocol_xml) def test_650_increment_still_rejected_on_525(self): @@ -236,8 +233,13 @@ async def test_expanded_survey_dry_and_transfer_path(self): self.assertEqual( [m for m, _ in srv.received], [ - "LockInstrument", "SetPlateMap", "PlateSurvey", "GetSurveyData", "DryPlate", - "DoWellTransfer", "UnlockInstrument", + "LockInstrument", + "SetPlateMap", + "PlateSurvey", + "GetSurveyData", + "DryPlate", + "DoWellTransfer", + "UnlockInstrument", ], ) diff --git a/pylabrobot/labcyte/echo_live_tests.py b/pylabrobot/labcyte/echo_live_tests.py index 5df25b74632..078a0ee7fe5 100644 --- a/pylabrobot/labcyte/echo_live_tests.py +++ b/pylabrobot/labcyte/echo_live_tests.py @@ -9,7 +9,6 @@ from pylabrobot.labcyte import Echo, EchoCommandError - ECHO_HOST = os.environ.get("PYLABROBOT_ECHO_HOST") EXPECTED_MODEL = os.environ.get("PYLABROBOT_ECHO_EXPECTED_MODEL", "Echo 650") VALIDATE_MOTION = os.environ.get("PYLABROBOT_ECHO_VALIDATE_MOTION") == "1" diff --git a/pylabrobot/labcyte/echo_mock.py b/pylabrobot/labcyte/echo_mock.py index a564034d398..64709a6ba33 100644 --- a/pylabrobot/labcyte/echo_mock.py +++ b/pylabrobot/labcyte/echo_mock.py @@ -32,11 +32,20 @@ from typing import Dict, Optional, Tuple, Type # Methods that require the caller to hold the instrument lock on a real Echo. -_LOCK_REQUIRED = frozenset({ - "DoWellTransfer", "PlateSurvey", "DryPlate", "CloseDoor", "OpenDoor", "HomeAxes", - "PresentSrcPlateGripper", "PresentDstPlateGripper", - "RetractSrcPlateGripper", "RetractDstPlateGripper", -}) +_LOCK_REQUIRED = frozenset( + { + "DoWellTransfer", + "PlateSurvey", + "DryPlate", + "CloseDoor", + "OpenDoor", + "HomeAxes", + "PresentSrcPlateGripper", + "PresentDstPlateGripper", + "RetractSrcPlateGripper", + "RetractDstPlateGripper", + } +) _REQUEST_METHOD = re.compile(rb"]*><([A-Za-z][A-Za-z0-9_]*)") _HEADER_END = b"\r\n\r\n" @@ -110,14 +119,15 @@ def _load_captured_responses() -> Dict[str, str]: blob = base64.b64decode("".join(_CAPTURED_RESPONSES_B64)) - return json.loads(gzip.decompress(blob).decode("utf-8")) + responses: Dict[str, str] = json.loads(gzip.decompress(blob).decode("utf-8")) + return responses def _generic_response(method: str, *, succeeded: bool = True, status: str = "OK") -> str: return ( '' '' - '' + "" f"<{method}Response><{method}>" f'{succeeded}' f'{status}' @@ -132,7 +142,7 @@ class EchoMockServer: def __init__(self, host: str = "127.0.0.1", port: int = 0): self._host = host self._requested_port = port - self._server: Optional[asyncio.AbstractServer] = None + self._server: Optional[asyncio.Server] = None self._responses = _load_captured_responses() self._locked = False #: every (method, was_locked) the server handled - handy for assertions in tests. @@ -148,14 +158,18 @@ def port(self) -> int: """The (possibly OS-assigned) port the mock is listening on; pass as ``rpc_port``.""" if self._server is None: raise RuntimeError("EchoMockServer is not running; use 'async with EchoMockServer()'.") - return self._server.sockets[0].getsockname()[1] + return int(self._server.sockets[0].getsockname()[1]) async def __aenter__(self) -> "EchoMockServer": await self.start() return self - async def __aexit__(self, exc_type: Optional[Type[BaseException]], - exc: Optional[BaseException], tb: Optional[TracebackType]) -> None: + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + tb: Optional[TracebackType], + ) -> None: await self.stop() async def start(self) -> None: diff --git a/pylabrobot/labcyte/echo_tests.py b/pylabrobot/labcyte/echo_tests.py index 9b28098768a..9ccec7ea1c6 100644 --- a/pylabrobot/labcyte/echo_tests.py +++ b/pylabrobot/labcyte/echo_tests.py @@ -17,9 +17,9 @@ EchoDryPlateMode, EchoDryPlateParams, EchoEvent, + EchoFluidInfo, EchoFocalSweepParams, EchoFocusState, - EchoFluidInfo, EchoPlateAccessBackend, EchoPlateCatalog, EchoPlateInfo, @@ -29,16 +29,16 @@ EchoPowerCalibrationResult, EchoProtocolError, EchoResolvedPlateType, - EchoScanPositions, EchoScannerCalibrationResult, + EchoScanPositions, EchoSurveyData, EchoSurveyParams, EchoSurveyRunResult, + EchoTransferPrintOptions, EchoTransferredWell, + EchoTransferResult, _HttpMessage, _RpcResult, - EchoTransferPrintOptions, - EchoTransferResult, build_echo_transfer_plan, create_plate_from_echo_info, ) diff --git a/pylabrobot/labcyte/picklist.py b/pylabrobot/labcyte/picklist.py index b893105fd49..ec53779a8e1 100644 --- a/pylabrobot/labcyte/picklist.py +++ b/pylabrobot/labcyte/picklist.py @@ -126,8 +126,7 @@ class EchoProtocolGenerator(ABC): """ @abstractmethod - def generate(self, transfers: List[Transfer]) -> List[GeneratedTransfer]: - ... + def generate(self, transfers: List[Transfer]) -> List[GeneratedTransfer]: ... def _wp(oid: int, t: Transfer) -> str: @@ -162,5 +161,7 @@ def generate(self, transfers: List[Transfer]) -> List[GeneratedTransfer]: f'' f"{source_type}{wps}" ) - out.append(GeneratedTransfer(source_plate_type=source_type, protocol_xml=xml, transfers=group)) + out.append( + GeneratedTransfer(source_plate_type=source_type, protocol_xml=xml, transfers=group) + ) return out diff --git a/pylabrobot/labcyte/picklist_tests.py b/pylabrobot/labcyte/picklist_tests.py index dd61edde9d5..a4166857682 100644 --- a/pylabrobot/labcyte/picklist_tests.py +++ b/pylabrobot/labcyte/picklist_tests.py @@ -15,8 +15,14 @@ class TestPicklistParsing(unittest.TestCase): def test_reads_standard_echo_cherry_pick_columns(self): rows = [ - {"Source Plate Name": "6res", "Source Well": "A2", "Destination Plate Name": "dst", - "Destination Well": "A1", "Volume": "150", "Source Plate Type": "6RES_AQ_BP2"}, + { + "Source Plate Name": "6res", + "Source Well": "A2", + "Destination Plate Name": "dst", + "Destination Well": "A1", + "Volume": "150", + "Source Plate Type": "6RES_AQ_BP2", + }, ] [t] = picklist_from_rows(rows) self.assertEqual((t.source_well, t.dest_well, t.volume_nl), ("A2", "A1", 150.0)) @@ -24,9 +30,15 @@ def test_reads_standard_echo_cherry_pick_columns(self): def test_reads_barcode_header_variant_with_offsets(self): rows = [ - {"Source Plate Barcode": "P1", "Source Well": "D5", "Destination Plate Barcode": "D1", - "Destination Well": "A1", "Transfer Volume": "25", "DestXOffset": "100", - "DestYOffset": "-50"}, + { + "Source Plate Barcode": "P1", + "Source Well": "D5", + "Destination Plate Barcode": "D1", + "Destination Well": "A1", + "Transfer Volume": "25", + "DestXOffset": "100", + "DestYOffset": "-50", + }, ] [t] = picklist_from_rows(rows) self.assertEqual(t.source_plate_name, "P1")