From e1d85f39a4c34c2484b8d1babe49d1072a9c2eb9 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Thu, 3 Sep 2026 19:11:42 +0800 Subject: [PATCH] Route OT-2 fixed-trash disposal by API version Text comparison treats API 26.6.0 as older than 7.1.0. Fixed-trash disposal then enters the tip-rack path and fails before sending the required commands. Parse the numeric release components and select the addressable-area commands only for fixed-trash disposal. Accept release suffixes, reject versions without a numeric release prefix, and keep ordinary tip returns independent of version parsing. --- .../backends/opentrons_backend.py | 28 +++++++++++++++++-- .../backends/opentrons_backend_tests.py | 24 ++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py index bff19ae91c4..501f6122702 100644 --- a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -1,5 +1,6 @@ import inspect import logging +import re import uuid from typing import Any, Dict, List, Optional, Tuple, Union, cast @@ -47,6 +48,28 @@ logger = logging.getLogger(__name__) +def _opentrons_version_components(version: str) -> Tuple[int, int, int]: + """Parse the numeric release components from an Opentrons server version. + + Raises: + ValueError: If the version does not start with major, minor, and patch numbers. + """ + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version) + if match is None: + raise ValueError( + f"Opentrons server version must start with major, minor, and patch numbers: {version!r}." + ) + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def _fixed_trash_is_addressable(api_version: str) -> bool: + """Return whether fixed trash uses the addressable-area commands.""" + return _opentrons_version_components(api_version) >= _opentrons_version_components( + _OT_DECK_IS_ADDRESSABLE_AREA_VERSION + ) + + class _IOLogger: """Transparent proxy over the ``ot_api`` module that logs every call at ``LOG_LEVEL_IO``. @@ -377,9 +400,8 @@ async def drop_tips(self, ops: List[Drop], use_channels: List[int]): pipette_id = self._get_drop_pipette(ops) op = ops[0] - use_fixed_trash = ( - cast(str, self.ot_api_version) >= _OT_DECK_IS_ADDRESSABLE_AREA_VERSION - and op.resource.name == "trash" + use_fixed_trash = op.resource.name == "trash" and _fixed_trash_is_addressable( + cast(str, self.ot_api_version) ) if use_fixed_trash: labware_id = "fixedTrash" diff --git a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py index 7f753803de8..5503b956a17 100644 --- a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py @@ -7,8 +7,8 @@ from pylabrobot.legacy.liquid_handling import LiquidHandler from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import ( - _OT_DECK_IS_ADDRESSABLE_AREA_VERSION, OpentronsOT2Backend, + _fixed_trash_is_addressable, ) from pylabrobot.legacy.liquid_handling.errors import NoChannelError from pylabrobot.legacy.liquid_handling.standard import ( @@ -36,6 +36,25 @@ def _mock_health_get(): } +@pytest.mark.parametrize( + ("version", "expected"), + ( + ("7.0.1", False), + ("7.1.0", True), + ("9.1.0-alpha.12", True), + ("9.1.0.dev12", True), + ("26.6.0", True), + ), +) +def test_fixed_trash_is_addressable(version: str, expected: bool) -> None: + assert _fixed_trash_is_addressable(version) is expected + + +def test_fixed_trash_rejects_invalid_server_version() -> None: + with pytest.raises(ValueError, match="must start with major, minor, and patch numbers"): + _fixed_trash_is_addressable("development") + + class OpentronsBackendSetupTests(unittest.IsolatedAsyncioTestCase): """Tests for setup and stop""" @@ -147,6 +166,7 @@ def assert_parameters(labware_id, well_name, pipette_id, offset_x, offset_y, off self.assertEqual(offset_z, offset_z) mock_drop_tip.side_effect = assert_parameters + self.backend.ot_api_version = "development" await self.test_tip_pick_up() await self.lh.drop_tips(self.tip_rack["A1"]) @@ -233,7 +253,7 @@ async def test_tip_drop_to_trash_uses_addressable_area( area (move_to_addressable_area_for_drop_tip + drop_tip_in_place), not drop_tip.""" mock_define.side_effect = _mock_define mock_add.side_effect = _mock_add - self.backend.ot_api_version = _OT_DECK_IS_ADDRESSABLE_AREA_VERSION + self.backend.ot_api_version = "26.6.0" await self.lh.pick_up_tips(self.tip_rack["A1"]) await self.lh.discard_tips()