From 930a17138f17e63733ad9cf217c6d6c5b6523557 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 22 Oct 2026 15:05:10 +0100 Subject: [PATCH] v1 `CLARIOstar`: refuse to close the drawer on a plate too tall for it - `Drawer.close()` checks the plate on the plate holder, its lid included, against `_maximum_plate_size_z` (20 mm) before anything is sent, and raises `ValueError` if it is taller; with no plate it closes as before. - Placing a plate is not checked: a tall plate can stand on the open drawer. - `stop()` raises the same error and leaves the drawer and the connection open. - Quit codes 242 and 243 are named: the wellplate sensor failed, or no wellplate is inserted or the focal height is too low for the one detected. Co-Authored-By: Claude Opus 5.5 --- .../bmg_labtech/clariostar/driver/errors.py | 6 ++-- .../clariostar/driver/features/drawer.py | 33 +++++++++++++++++-- .../driver/features/drawer_tests.py | 25 ++++++++++++++ .../bmg_labtech/clariostar/driver/master.py | 2 ++ .../clariostar/driver/simulator_tests.py | 11 +++++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/errors.py b/pylabrobot/bmg_labtech/clariostar/driver/errors.py index f043db48eb5..09f251283fa 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/errors.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/errors.py @@ -34,8 +34,8 @@ def parse_warning_codes(reply: bytes) -> Optional[Tuple[int, int, int, int, int] return _quit_codes(reply, WARNING_BIT) -# What the first quit code means, for the codes seen in replies from a reader. 1-3 mean the reader -# received a malformed frame. +# What the first quit code means, for the codes seen in replies from a reader, and 242 and 243 not +# seen yet. 1-3 mean the reader received a malformed frame. QUIT_CODE_DESCRIPTIONS: Dict[int, str] = { 1: "the reader received a frame with a wrong checksum", 2: "the reader received a frame with a wrong end byte", @@ -48,6 +48,8 @@ def parse_warning_codes(reply: bytes) -> Optional[Tuple[int, int, int, int, int] 202: "the reader is busy", 213: "no measurement results are available", 214: "no test run is active", + 242: "the wellplate sensor failed or could not be read", + 243: "no wellplate is inserted, or the focal height is too low for the one detected", 250: "the motor controller reported a collision", } diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/drawer.py b/pylabrobot/bmg_labtech/clariostar/driver/features/drawer.py index 82e24ef47e9..091a52a5c5c 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/drawer.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/drawer.py @@ -3,7 +3,7 @@ import logging from typing import TYPE_CHECKING, Optional -from pylabrobot.resources import Coordinate, PlateHolder +from pylabrobot.resources import Coordinate, Plate, PlateHolder if TYPE_CHECKING: from ..master import CLARIOstarDriver @@ -25,6 +25,9 @@ class Drawer: default_move_timeout: float = 20.0 # Seconds between status polls while the drawer moves; as the driver's `default_poll_interval`. default_poll_interval: float = 0.25 + # The tallest plate, lid included, the drawer closes with, in mm: a cautious limit until the + # reader's clearance is measured. + _maximum_plate_size_z: float = 20.0 def __init__(self, driver: "CLARIOstarDriver"): """ @@ -102,7 +105,33 @@ async def open(self) -> None: await self._move(open_drawer=True) logger.info("drawer open") + def _check_plate_size_z(self) -> None: + """Raise if the plate on the plate holder, with its lid, is taller than the drawer takes. + + Raises: + ValueError: if the plate is above `_maximum_plate_size_z`. + """ + plate = self.plate_holder.resource if self.plate_holder is not None else None + if plate is None: + return + height = plate.get_size_z() + lid = plate.lid if isinstance(plate, Plate) else None + if lid is not None: + height = max(height, plate.get_lid_location(lid).z + lid.get_size_z()) + if height > self._maximum_plate_size_z: + with_lid = " with its lid" if lid is not None else "" + raise ValueError( + f"cannot close the drawer: {plate.name} is {height:g} mm high{with_lid}; the drawer takes" + f" at most {self._maximum_plate_size_z:g} mm" + ) + async def close(self) -> None: - """Move the drawer in (about 8 s).""" + """Move the drawer in (about 8 s). + + Raises: + ValueError: if the plate on the plate holder, with its lid, is above + `_maximum_plate_size_z`; nothing is sent. + """ + self._check_plate_size_z() await self._move(open_drawer=False) logger.info("drawer closed") diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/drawer_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/features/drawer_tests.py index c7fe7b69cbd..d26afc11e95 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/drawer_tests.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/drawer_tests.py @@ -13,6 +13,7 @@ make_driver, ) from pylabrobot.resources import Coordinate, PlateHolder +from pylabrobot.resources.corning.plates import cor_96_wellplate_2mL_Vb, cor_96_wellplate_360uL_Fb # Frames this driver exchanged with a CLARIOstar Plus (FTDI, firmware 1.35) on its first runs. OPEN_DRAWER_FRAME = bytes.fromhex("02000e0c0301000000000000200d") @@ -72,6 +73,30 @@ async def test_close_updates_in(self): ) self.assertEqual(self.plate_holder.location, LOCATION_IN) + async def test_close_with_a_plate_too_tall_sends_nothing(self): + self.plate_holder.assign_child_resource(cor_96_wellplate_2mL_Vb("deep_well")) # 43.5 mm + with self.assertRaises(ValueError): + await self.driver.drawer.close() + self.assertEqual(self.io.written, []) + + async def test_close_with_a_plate_at_the_limit_closes(self): + plate = cor_96_wellplate_360uL_Fb("plate") + self.plate_holder.assign_child_resource(plate) + self.driver.drawer._maximum_plate_size_z = plate.get_size_z() + self.io.replies = [STATUS_DRAWER_OPEN, CLOSE_DRAWER_REPLY, STATUS_IDLE, STATUS_IDLE] + await self.driver.drawer.close() + self.assertEqual( + self.io.written, [STATUS_QUERY_FRAME, CLOSE_DRAWER_FRAME] + [STATUS_QUERY_FRAME] * 2 + ) + + async def test_a_lid_counts_towards_the_height(self): + plate = cor_96_wellplate_360uL_Fb("lidded", with_lid=True) + self.plate_holder.assign_child_resource(plate) + self.driver.drawer._maximum_plate_size_z = plate.get_size_z() # the lid stands above it + with self.assertRaisesRegex(ValueError, "with its lid"): + await self.driver.drawer.close() + self.assertEqual(self.io.written, []) + async def test_idle_but_not_open_times_out(self): self.driver.drawer.default_move_timeout = 0 self.io.replies = [STATUS_IDLE, OPEN_DRAWER_REPLY, STATUS_IDLE, STATUS_IDLE] diff --git a/pylabrobot/bmg_labtech/clariostar/driver/master.py b/pylabrobot/bmg_labtech/clariostar/driver/master.py index 8a797c940ad..3da6760b1fc 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/master.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/master.py @@ -1219,6 +1219,8 @@ async def stop(self, accept_plate_left_in_device: bool = False) -> None: Raises: RuntimeError: if a plate is still in the reader, unless `accept_plate_left_in_device`; the drawer is opened and the connection stays open. + ValueError: if the plate on the plate holder is too tall to close the drawer with; the + drawer stays open and the connection stays open. """ await self._shut_down(accept_plate_left_in_device) self._stop_queued_reads() # before the device handle under them is closed diff --git a/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py index d220df6d881..2e75d12b55a 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py @@ -26,6 +26,7 @@ ) from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.biorad.plates import biorad_96_wellplate_200uL_Vb +from pylabrobot.resources.corning.plates import cor_96_wellplate_2mL_Vb LOCATION_IN = Coordinate(10, 20, 30) # made up: the real ones are not measured yet LOCATION_OUT = Coordinate(10, -200, 30) @@ -133,6 +134,16 @@ async def test_stop_with_a_plate_inside_opens_the_drawer_and_raises(self): self.assertTrue(await self.reader.drawer.sense_drawer_open()) await self.reader.stop(accept_plate_left_in_device=True) + async def test_stop_with_a_plate_too_tall_raises_and_leaves_the_drawer_open(self): + await self.reader.drawer.open() + tall = cor_96_wellplate_2mL_Vb("deep_well") # 43.5 mm + self.reader.plate_holder.assign_child_resource(tall) + with self.assertRaises(ValueError): + await self.reader.stop() + self.assertTrue(await self.reader.drawer.sense_drawer_open()) + tall.unassign() + await self.reader.stop() + async def test_stop_switches_heating_off_and_closes_the_drawer(self): temperature_controller = self.reader.temperature_controller assert temperature_controller is not None