From 818c0dbeff61d63ee4306c54e60003372f30c9dc Mon Sep 17 00:00:00 2001 From: rick <7keerrickendan1@gmail.com> Date: Fri, 12 Jun 2026 17:57:04 -0700 Subject: [PATCH 01/23] feat(highres): add TundraStore plate store driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a PyLabRobot driver for the HighRes Biosolutions TundraStore (branded "SteriStore") refrigerated automated plate store, which speaks a line-based text protocol over TCP port 1000. - TundraStoreBackend: pylabrobot.io.socket.Socket transport with ACK / data / completion (OK!/ABORTED!/ERROR!) framing and error-stack parsing. Implements AutomatedRetrieval, TemperatureController and (read-only) HumidityController capabilities, plus low-level home/pick/place/door/ barcode commands and status queries (version, doors, nests, spatula, environment, stacker dimensions). - TundraStoreChatterboxBackend for device-free use. - TundraStore frontend (Resource + Device) modeled on Cytomat. - Tests driven by real captured firmware-3.0.0.119 responses. Note: the device has two transfer nests; mapping the single-loading-tray AutomatedRetrieval model onto both is left as a follow-up — the capability currently uses a configurable single nest (default 1). Co-Authored-By: Claude Opus 4.8 --- pylabrobot/highres/__init__.py | 0 pylabrobot/highres/tundrastore/__init__.py | 12 + pylabrobot/highres/tundrastore/backend.py | 390 ++++++++++++++++++ .../highres/tundrastore/backend_tests.py | 163 ++++++++ pylabrobot/highres/tundrastore/chatterbox.py | 71 ++++ pylabrobot/highres/tundrastore/constants.py | 33 ++ pylabrobot/highres/tundrastore/errors.py | 25 ++ pylabrobot/highres/tundrastore/standard.py | 57 +++ pylabrobot/highres/tundrastore/tundrastore.py | 147 +++++++ 9 files changed, 898 insertions(+) create mode 100644 pylabrobot/highres/__init__.py create mode 100644 pylabrobot/highres/tundrastore/__init__.py create mode 100644 pylabrobot/highres/tundrastore/backend.py create mode 100644 pylabrobot/highres/tundrastore/backend_tests.py create mode 100644 pylabrobot/highres/tundrastore/chatterbox.py create mode 100644 pylabrobot/highres/tundrastore/constants.py create mode 100644 pylabrobot/highres/tundrastore/errors.py create mode 100644 pylabrobot/highres/tundrastore/standard.py create mode 100644 pylabrobot/highres/tundrastore/tundrastore.py diff --git a/pylabrobot/highres/__init__.py b/pylabrobot/highres/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py new file mode 100644 index 00000000000..e9056b835bb --- /dev/null +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -0,0 +1,12 @@ +from .backend import TundraStoreBackend +from .chatterbox import TundraStoreChatterboxBackend +from .constants import DoorState, NestState +from .errors import TundraStoreAbortedError, TundraStoreError +from .standard import ( + DoorStatus, + EnvironmentParameter, + NestStatus, + StackerDimensions, + VersionInfo, +) +from .tundrastore import NoFreeSiteError, TundraStore diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py new file mode 100644 index 00000000000..ba666f6f987 --- /dev/null +++ b/pylabrobot/highres/tundrastore/backend.py @@ -0,0 +1,390 @@ +import asyncio +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver +from pylabrobot.io.socket import Socket +from pylabrobot.resources import Plate, PlateHolder + +from .constants import ( + ACK_TOKEN, + COMPLETION_ABORTED, + COMPLETION_ERROR, + COMPLETION_OK, + COMPLETION_TOKENS, + DoorState, + NestState, +) +from .errors import TundraStoreAbortedError, TundraStoreError +from .standard import ( + DoorStatus, + EnvironmentParameter, + NestStatus, + StackerDimensions, + VersionInfo, +) + +logger = logging.getLogger(__name__) + + +class TundraStoreBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + Driver, +): + """Backend for the HighRes Biosolutions TundraStore automated plate store. + + The TundraStore (also sold/branded as "SteriStore") exposes a text-based + remote-control server over TCP, port 1000. Commands are case-sensitive, + space-separated, terminated with ``\\r\\n``. Each command is answered with an + ``ACK!`` echo, optional data lines, then exactly one completion line + (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the TundraStore User Manual, + section "Message Formatting". + + Plates are stored in a refrigerated carousel of *stackers*, each holding a + number of *slots*. An external robot hands plates to/from one of the device's + *nests* (transfer stations); the internal spatula moves plates between a nest + and a (stacker, slot). The low-level :meth:`pick` / :meth:`place` take those + three indices directly. + + Note: the TundraStore has two nests. Mapping PyLabRobot's single + loading-tray-centric :class:`AutomatedRetrieval` capability onto two nests is + an open design question; for now the capability methods use + :attr:`loading_tray_nest` (default 1). Use :meth:`pick` / :meth:`place` + directly to address a specific nest. + """ + + @dataclass + class SetupParams(BackendParams): + """Optional parameters for :meth:`setup`.""" + + home_on_setup: bool = False + + def __init__( + self, + host: str, + port: int = 1000, + read_timeout: float = 30.0, + motion_timeout: float = 240.0, + loading_tray_nest: int = 1, + ): + """ + Args: + host: IP address of the TundraStore. The factory default is + ``192.168.127.60``; all HighRes devices also answer on the backdoor + ``10.253.253.253``. + port: Remote-control server port (always 1000). + read_timeout: Timeout (s) for query/status commands. + motion_timeout: Timeout (s) for long-running motion commands + (``home``, ``pick``, ``place``, door moves). + loading_tray_nest: Which nest the :class:`AutomatedRetrieval` capability + uses as its loading tray (1 or 2). + """ + super().__init__() + self.io = Socket( + human_readable_device_name="HighRes TundraStore", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=read_timeout, + ) + self._read_timeout = read_timeout + self._motion_timeout = motion_timeout + self.loading_tray_nest = loading_tray_nest + self._command_lock = asyncio.Lock() + # stacker/slot lookup for the AutomatedRetrieval capability, built from racks. + self._site_locations: Dict[str, Tuple[int, int]] = {} + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.io._host, + "port": self.io._port, + "read_timeout": self._read_timeout, + "motion_timeout": self._motion_timeout, + "loading_tray_nest": self.loading_tray_nest, + } + + # --- lifecycle ------------------------------------------------------------ + + async def setup(self, backend_params: Optional[BackendParams] = None): + if backend_params is None: + backend_params = TundraStoreBackend.SetupParams() + if not isinstance(backend_params, TundraStoreBackend.SetupParams): + raise TypeError(f"backend_params must be {TundraStoreBackend.SetupParams}") + + await self.io.setup() + version = await self.request_version() + logger.info( + "Connected to %s (serial %s, firmware %s)", + version.product_name, + version.serial_number, + version.firmware_version, + ) + if backend_params.home_on_setup: + await self.home() + + async def stop(self): + await self.io.stop() + + # --- transport ------------------------------------------------------------ + + async def _readline(self, timeout: Optional[float]) -> str: + raw = await self.io.readuntil(b"\n", timeout=timeout) + return raw.decode("ascii", errors="replace").rstrip("\r\n") + + async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: + """Send a command and return its data lines (those between the ``ACK!`` echo + and the completion line). + + Raises: + TundraStoreError: if the device replies ``ERROR!``. + TundraStoreAbortedError: if the device replies ``ABORTED!``. + """ + if timeout is None: + timeout = self._read_timeout + async with self._command_lock: + await self.io.write(command.encode("ascii") + b"\r\n") + + data_lines: List[str] = [] + completion: Optional[str] = None + seen_ack = False + while completion is None: + line = await self._readline(timeout) + if line.startswith(ACK_TOKEN) and not seen_ack: + seen_ack = True + continue + if line.startswith(COMPLETION_TOKENS): + completion = line + break + data_lines.append(line) + + if completion.startswith(COMPLETION_ERROR): + # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* + # the ERROR! completion, so they are already collected in data_lines. + error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines + raise TundraStoreError(command, error_lines) + if completion.startswith(COMPLETION_ABORTED): + raise TundraStoreAbortedError(command) + assert completion.startswith(COMPLETION_OK) + return data_lines + + @staticmethod + def _parse_kv(lines: List[str]) -> Dict[str, str]: + """Parse ``Key: value`` lines into a dict (first colon splits).""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out + + # --- queries (verified against firmware 3.0.0.119) ------------------------ + + async def request_version(self) -> VersionInfo: + raw = self._parse_kv(await self.send_command("version")) + return VersionInfo( + product_name=raw.get("Product Name"), + serial_number=raw.get("Serial Number"), + firmware_version=raw.get("Firmware Version"), + firmware_build=raw.get("Firmware Build"), + raw=raw, + ) + + async def request_axis_positions(self) -> Dict[str, float]: + """Return the ``status`` report: carousel/theta/Y/Z positions.""" + out: Dict[str, float] = {} + for key, value in self._parse_kv(await self.send_command("status")).items(): + try: + out[key] = float(value) + except ValueError: + continue + return out + + async def is_homed(self) -> bool: + lines = await self.send_command("homedstatus") + return any(line.strip().lower() == "homed" for line in lines) + + async def request_door_status(self) -> DoorStatus: + doors: Dict[str, DoorState] = {} + for name, value in self._parse_kv(await self.send_command("doorstatus")).items(): + try: + doors[name] = DoorState(value) + except ValueError: + doors[name] = DoorState.UNKNOWN + return DoorStatus(doors=doors) + + async def request_nest_status(self) -> NestStatus: + nests: Dict[int, NestState] = {} + for key, value in self._parse_kv(await self.send_command("neststatus")).items(): + try: + nest = int(key) + except ValueError: + continue + try: + nests[nest] = NestState(value) + except ValueError: + nests[nest] = NestState.UNKNOWN + return NestStatus(nests=nests) + + async def request_plate_on_spatula(self) -> bool: + """Whether a plate is currently held on the spatula (``platestatus``).""" + lines = await self.send_command("platestatus") + return not any("NO_PLATE" in line for line in lines) + + async def request_environment(self) -> Dict[str, EnvironmentParameter]: + """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. + + Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels + (e.g. the gas tank pressures) report only a current value. + """ + out: Dict[str, EnvironmentParameter] = {} + for line in await self.send_command("environmentstatus"): + if ":" not in line: + continue + name, _, rest = line.partition(":") + parts = rest.strip().rstrip(":").split("/") + try: + current = float(parts[0]) + except (ValueError, IndexError): + continue + + def _opt(i: int) -> Optional[float]: + try: + return float(parts[i]) + except (ValueError, IndexError): + return None + + out[name.strip()] = EnvironmentParameter( + name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) + ) + return out + + async def get_stacker_dimensions(self) -> List[StackerDimensions]: + """Parse ``getstackerdimensions`` (``: + ``).""" + dims: List[StackerDimensions] = [] + for line in await self.send_command("getstackerdimensions"): + key, _, rest = line.partition(":") + try: + stacker = int(key) + zero_offset, slot_height, slot_count = rest.split() + dims.append( + StackerDimensions( + stacker=stacker, + zero_offset=float(zero_offset), + slot_height=float(slot_height), + slot_count=int(slot_count), + ) + ) + except ValueError: + continue + return dims + + async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: + """Scan a stacker (or a single slot) for barcodes. + + Args: + stacker: Stacker number, or the string ``"all"`` to scan the whole + inventory. + slot: Optional single slot to scan. + """ + command = f"barcode {stacker}" + if slot is not None: + command += f" {slot}" + return await self.send_command(command, timeout=self._motion_timeout) + + # --- motion --------------------------------------------------------------- + + async def home(self): + """Home the system. The first step closes all doors, which requires the + pneumatic supply (clean dry air >80 psi); without it this raises + :class:`TundraStoreError` ("Unable to close all doors").""" + await self.send_command("home", timeout=self._motion_timeout) + + async def pick(self, stacker: int, slot: int, nest: int): + """Retrieve a plate from ``(stacker, slot)`` to ``nest``.""" + await self.send_command(f"pick {stacker} {slot} {nest}", timeout=self._motion_timeout) + + async def place(self, stacker: int, slot: int, nest: int): + """Place the plate at ``nest`` into ``(stacker, slot)``.""" + await self.send_command(f"place {stacker} {slot} {nest}", timeout=self._motion_timeout) + + async def open_all_doors(self): + await self.send_command("openalldoors", timeout=self._motion_timeout) + + async def close_all_doors(self): + await self.send_command("closealldoors", timeout=self._motion_timeout) + + async def abort(self): + """Stop current machine operations. ``clear_abort`` is required afterward.""" + await self.send_command("abort") + + async def clear_abort(self): + await self.send_command("clearabort") + + # --- AutomatedRetrieval capability ---------------------------------------- + + async def set_racks(self, racks): + """Register the storage racks so the capability can resolve a plate/site to + a ``(stacker, slot)``. Rack *i* (0-based) maps to stacker ``i + 1``; site + *j* within a rack maps to slot ``j + 1``.""" + self._site_locations = {} + for rack_index, rack in enumerate(racks): + for slot_index, site in enumerate(rack.sites.values()): + self._site_locations[site.name] = (rack_index + 1, slot_index + 1) + + def _locate(self, site: PlateHolder) -> Tuple[int, int]: + if site.name not in self._site_locations: + raise ValueError(f"Site '{site.name}' is not a known stacker slot; call set_racks() first.") + return self._site_locations[site.name] + + async def fetch_plate_to_loading_tray(self, plate: Plate): + site = plate.parent + if not isinstance(site, PlateHolder): + raise ValueError(f"Plate '{plate.name}' is not in a stacker slot.") + stacker, slot = self._locate(site) + await self.pick(stacker, slot, self.loading_tray_nest) + + async def store_plate(self, plate: Plate, site: PlateHolder): + stacker, slot = self._locate(site) + await self.place(stacker, slot, self.loading_tray_nest) + + # --- TemperatureController capability ------------------------------------- + + @property + def supports_active_cooling(self) -> bool: + return True # refrigerated store (-20 to 4 C) + + async def request_current_temperature(self) -> float: + env = await self.request_environment() + if "TEMP" not in env: + raise TundraStoreError("environmentstatus", ["no TEMP channel reported"]) + return env["TEMP"].current + + async def set_temperature(self, temperature: float): + await self.send_command(f"environmentset TEMP {temperature}") + + async def deactivate(self): + await self.send_command("environment TEMP off") + + # --- HumidityController capability (read-only monitoring) ----------------- + + @property + def supports_humidity_control(self) -> bool: + return False + + async def request_current_humidity(self) -> float: + env = await self.request_environment() + if "RH" not in env: + raise TundraStoreError("environmentstatus", ["no RH channel reported"]) + return env["RH"].current / 100.0 + + async def set_humidity(self, humidity: float): + raise NotImplementedError("The TundraStore does not support active humidity control.") diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py new file mode 100644 index 00000000000..5dc7eb7cbed --- /dev/null +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -0,0 +1,163 @@ +import unittest +from typing import Dict, List + +from pylabrobot.highres.tundrastore.backend import TundraStoreBackend +from pylabrobot.highres.tundrastore.constants import DoorState, NestState +from pylabrobot.highres.tundrastore.errors import TundraStoreError + +# Real responses captured from a TundraStore (firmware 3.0.0.119, serial +# HRB-2209-35148) over the port-1000 remote-control server. +CAPTURES: Dict[str, List[str]] = { + "version": [ + "ACK! version 1", + "Product Name: SteriStore", + "Serial Number: HRB-2209-35148", + "libcommon Version: 1.1.0.119", + "libts7600 Version: 1.0.0.119", + "Firmware Version: 3.0.0.119", + "Firmware Build: D9BE232A", + "OK! version 1", + ], + "homedstatus": ["ACK! homedstatus 5", "not homed", "OK! homedstatus 5"], + "doorstatus": [ + "ACK! doorstatus 17", + "User Door: CLOSED", + "RI: CLOSED", + "SEAL: CLOSING", + "RO1: CLOSING", + "RO2: CLOSED", + "RO3: CLOSED", + "RO4: CLOSED", + "OK! doorstatus 17", + ], + "platestatus": ["ACK! platestatus 9", "NO_PLATE", "OK! platestatus 9"], + "neststatus": ["ACK! neststatus 11", "1: CLEAR", "2: CLEAR", "OK! neststatus 11"], + "environmentstatus": [ + "ACK! environmentstatus 31", + "TEMP:21.9/22.0/100.0", + "RH:54.7/0.0/-100.0", + "CO2:0.0/5.0/100.0", + "O2:20.5/5.0/100.0", + "TANK1:135.0:", + "TANK2:135.0:", + "OK! environmentstatus 31", + ], + "getstackerdimensions": [ + "ACK! getstackerdimensions 35", + "1: 0.000 28.940 0", + "2: 0.000 22.867 24", + "13: 0.000 22.867 0", + "OK! getstackerdimensions 35", + ], + # The home command failed because the pneumatic doors could not close (no air). + "home": [ + "ACK! home 13", + "Error 1: (00:32:44) 13: Unable to close all doors", + "ERROR! home 13", + ], +} + + +class FakeSocket: + """Replays scripted line responses keyed by the command written to it.""" + + def __init__(self, captures: Dict[str, List[str]]): + self.captures = captures + self.written: List[str] = [] + self._queue: List[str] = [] + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes, timeout=None): + command = data.decode("ascii").rstrip("\r\n") + self.written.append(command) + self._queue = list(self.captures[command]) + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + return self._queue.pop(0).encode("ascii") + b"\r\n" + + +class TundraStoreBackendTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.backend = TundraStoreBackend(host="10.253.253.253") + self.socket = FakeSocket(CAPTURES) + self.backend.io = self.socket # type: ignore[assignment] + + async def test_send_command_strips_ack_and_completion(self): + data = await self.backend.send_command("neststatus") + self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) + self.assertEqual(self.socket.written, ["neststatus"]) + + async def test_version(self): + v = await self.backend.request_version() + self.assertEqual(v.product_name, "SteriStore") + self.assertEqual(v.serial_number, "HRB-2209-35148") + self.assertEqual(v.firmware_version, "3.0.0.119") + self.assertEqual(v.firmware_build, "D9BE232A") + + async def test_is_homed(self): + self.assertFalse(await self.backend.is_homed()) + + async def test_door_status(self): + status = await self.backend.request_door_status() + self.assertEqual(status.doors["User Door"], DoorState.CLOSED) + self.assertEqual(status.doors["SEAL"], DoorState.CLOSING) + self.assertEqual(status.doors["RO1"], DoorState.CLOSING) + self.assertFalse(status.all_closed) + + async def test_nest_status(self): + status = await self.backend.request_nest_status() + self.assertEqual(status.nests, {1: NestState.CLEAR, 2: NestState.CLEAR}) + + async def test_plate_on_spatula(self): + self.assertFalse(await self.backend.request_plate_on_spatula()) + + async def test_environment_parsing(self): + env = await self.backend.request_environment() + self.assertAlmostEqual(env["TEMP"].current, 21.9) + self.assertIsNotNone(env["TEMP"].setpoint) + assert env["TEMP"].setpoint is not None # narrow for type checker + self.assertAlmostEqual(env["TEMP"].setpoint, 22.0) + self.assertAlmostEqual(env["O2"].current, 20.5) + # Sensor-only channel: current value, no setpoint. + self.assertAlmostEqual(env["TANK1"].current, 135.0) + self.assertIsNone(env["TANK1"].setpoint) + + async def test_temperature_capability_reads_temp_channel(self): + self.assertAlmostEqual(await self.backend.request_current_temperature(), 21.9) + self.assertTrue(self.backend.supports_active_cooling) + + async def test_humidity_capability_reads_rh_as_fraction(self): + self.assertAlmostEqual(await self.backend.request_current_humidity(), 0.547) + self.assertFalse(self.backend.supports_humidity_control) + + async def test_stacker_dimensions(self): + dims = await self.backend.get_stacker_dimensions() + self.assertEqual(dims[0].stacker, 1) + self.assertEqual(dims[0].slot_count, 0) + self.assertEqual(dims[1].stacker, 2) + self.assertAlmostEqual(dims[1].slot_height, 22.867) + self.assertEqual(dims[1].slot_count, 24) + + async def test_home_error_raises_with_stack_detail(self): + with self.assertRaises(TundraStoreError) as ctx: + await self.backend.home() + self.assertIn("Unable to close all doors", str(ctx.exception)) + self.assertEqual(ctx.exception.command, "home") + + async def test_pick_formats_command(self): + self.socket.captures["pick 3 12 1"] = ["ACK! pick 3 12 1 99", "OK! pick 3 12 1 99"] + await self.backend.pick(3, 12, 1) + self.assertEqual(self.socket.written, ["pick 3 12 1"]) + + async def test_set_humidity_unsupported(self): + with self.assertRaises(NotImplementedError): + await self.backend.set_humidity(0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/highres/tundrastore/chatterbox.py b/pylabrobot/highres/tundrastore/chatterbox.py new file mode 100644 index 00000000000..378000dae2c --- /dev/null +++ b/pylabrobot/highres/tundrastore/chatterbox.py @@ -0,0 +1,71 @@ +import logging +from typing import Optional + +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver +from pylabrobot.resources import Plate, PlateHolder + +logger = logging.getLogger(__name__) + + +class TundraStoreChatterboxBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + Driver, +): + """Device-free TundraStore backend that logs calls instead of talking to + hardware. Useful for testing protocols and resource assignment offline.""" + + def __init__(self, temperature: float = 4.0, humidity: float = 0.5): + super().__init__() + self._temperature = temperature + self._humidity = humidity + + async def setup(self, backend_params: Optional[BackendParams] = None): + logger.info("[tundrastore] setup") + + async def stop(self): + logger.info("[tundrastore] stop") + + async def home(self): + logger.info("[tundrastore] home") + + async def pick(self, stacker: int, slot: int, nest: int): + logger.info("[tundrastore] pick stacker=%d slot=%d nest=%d", stacker, slot, nest) + + async def place(self, stacker: int, slot: int, nest: int): + logger.info("[tundrastore] place stacker=%d slot=%d nest=%d", stacker, slot, nest) + + async def fetch_plate_to_loading_tray(self, plate: Plate): + logger.info("[tundrastore] fetch plate %s to loading tray", plate.name) + + async def store_plate(self, plate: Plate, site: PlateHolder): + logger.info("[tundrastore] store plate %s at site %s", plate.name, site.name) + + @property + def supports_active_cooling(self) -> bool: + return True + + async def request_current_temperature(self) -> float: + return self._temperature + + async def set_temperature(self, temperature: float): + logger.info("[tundrastore] set temperature %.1f C", temperature) + self._temperature = temperature + + async def deactivate(self): + logger.info("[tundrastore] deactivate temperature control") + + @property + def supports_humidity_control(self) -> bool: + return False + + async def request_current_humidity(self) -> float: + return self._humidity + + async def set_humidity(self, humidity: float): + raise NotImplementedError("The TundraStore does not support active humidity control.") diff --git a/pylabrobot/highres/tundrastore/constants.py b/pylabrobot/highres/tundrastore/constants.py new file mode 100644 index 00000000000..46caf5b1cf1 --- /dev/null +++ b/pylabrobot/highres/tundrastore/constants.py @@ -0,0 +1,33 @@ +import enum + + +class DoorState(enum.Enum): + """State of a single TundraStore door, as reported by ``doorstatus``.""" + + OPEN = "OPEN" + CLOSED = "CLOSED" + OPENING = "OPENING" + CLOSING = "CLOSING" + UNKNOWN = "UNKNOWN" + + +class NestState(enum.Enum): + """State of a transfer nest, as reported by ``neststatus``.""" + + CLEAR = "CLEAR" + OCCUPIED = "OCCUPIED" + UNKNOWN = "UNKNOWN" + + +# Completion-status tokens that terminate a command's reply (see the manual, +# "Message Formatting"). Every command ends with exactly one of these. +COMPLETION_OK = "OK!" +COMPLETION_ABORTED = "ABORTED!" +COMPLETION_ERROR = "ERROR!" +COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) + +# Immediate command-receipt echo prefix. +ACK_TOKEN = "ACK!" + +# Intermediate data line emitted by ``pick`` once the plate is clear of the nest. +PLATE_AVAILABLE = "PLATE_AVAILABLE" diff --git a/pylabrobot/highres/tundrastore/errors.py b/pylabrobot/highres/tundrastore/errors.py new file mode 100644 index 00000000000..faaded1cbe2 --- /dev/null +++ b/pylabrobot/highres/tundrastore/errors.py @@ -0,0 +1,25 @@ +from typing import List + + +class TundraStoreError(Exception): + """A command returned an ``ERROR!`` completion status. + + The TundraStore reports failures as an error stack: the completion line is + preceded (firmware 3.0.x) by one or more ``Error : ...`` lines, the last of + which is generally the most pertinent. Those lines are preserved in + :attr:`error_lines`. + """ + + def __init__(self, command: str, error_lines: List[str]): + self.command = command + self.error_lines = error_lines + detail = error_lines[-1] if error_lines else "no error detail returned" + super().__init__(f"'{command}' failed: {detail}") + + +class TundraStoreAbortedError(Exception): + """A command returned an ``ABORTED!`` completion status (e.g. after ``abort``).""" + + def __init__(self, command: str): + self.command = command + super().__init__(f"'{command}' was aborted") diff --git a/pylabrobot/highres/tundrastore/standard.py b/pylabrobot/highres/tundrastore/standard.py new file mode 100644 index 00000000000..2678eb4c980 --- /dev/null +++ b/pylabrobot/highres/tundrastore/standard.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +from .constants import DoorState, NestState + + +@dataclass +class VersionInfo: + """Parsed output of the ``version`` command.""" + + product_name: Optional[str] + serial_number: Optional[str] + firmware_version: Optional[str] + firmware_build: Optional[str] + raw: Dict[str, str] + + +@dataclass +class EnvironmentParameter: + """One row of ``environmentstatus`` (e.g. TEMP, RH, CO2, O2). + + The device reports ``NAME:current/setpoint/limit``. ``setpoint``/``limit`` are + ``None`` for sensor-only channels (e.g. the gas tank pressures). + """ + + name: str + current: float + setpoint: Optional[float] = None + limit: Optional[float] = None + + +@dataclass +class DoorStatus: + """Parsed output of the ``doorstatus`` command, keyed by door name.""" + + doors: Dict[str, DoorState] + + @property + def all_closed(self) -> bool: + return all(state is DoorState.CLOSED for state in self.doors.values()) + + +@dataclass +class NestStatus: + """Parsed output of the ``neststatus`` command, keyed by nest number.""" + + nests: Dict[int, NestState] + + +@dataclass +class StackerDimensions: + """One stacker's geometry, from ``getstackerdimensions``.""" + + stacker: int + zero_offset: float + slot_height: float + slot_count: int diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/tundrastore/tundrastore.py new file mode 100644 index 00000000000..a14ecb10475 --- /dev/null +++ b/pylabrobot/highres/tundrastore/tundrastore.py @@ -0,0 +1,147 @@ +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling import HumidityController +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + ResourceNotFoundError, + Rotation, +) +from pylabrobot.resources.resource import Resource + +from .backend import TundraStoreBackend + + +class NoFreeSiteError(Exception): + pass + + +class TundraStore(Resource, Device): + """HighRes Biosolutions TundraStore refrigerated plate store. + + Each rack is a *stacker* (a vertical column of plate slots); plates enter and + leave through a *nest* (transfer station). The store has two nests, but + mapping PyLabRobot's single-loading-tray :class:`AutomatedRetrieval` model + onto two nests is still an open design decision, so for now this frontend + drives a single configurable nest (see ``loading_tray_nest`` on the backend). + """ + + def __init__( + self, + name: str, + driver: TundraStoreBackend, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = "plate_store", + model: Optional[str] = "TundraStore", + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: TundraStoreBackend = driver + + self.loading_tray = PlateHolder( + name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.retrieval = AutomatedRetrieval(backend=driver) + self.tc = TemperatureController(backend=driver) + self.humidity = HumidityController(backend=driver) + self._capabilities = [self.tc, self.humidity, self.retrieval] + + @property + def racks(self) -> List[PlateCarrier]: + return self._racks + + async def setup(self, backend_params: Optional[BackendParams] = None): + await super().setup(backend_params=backend_params) + await self.driver.set_racks(self._racks) + + def get_num_free_sites(self) -> int: + return sum(len(rack.get_free_sites()) for rack in self._racks) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") + + def _available_sites(self, plate: Plate) -> List[PlateHolder]: + def height(p: Plate) -> float: + return p.get_size_z() + (3 if p.has_lid() else 0) + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= height(plate) + ] + if not available: + raise NoFreeSiteError(f"No free site in '{self.name}' for plate '{plate.name}'") + return sorted(available, key=lambda s: s.get_size_z()) + + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + """Retrieve a stored plate and place it on the loading-tray nest.""" + site = self.get_site_by_plate_name(plate_name) + plate = cast(Plate, site.resource) + await self.retrieval.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + """Store the plate currently on the loading-tray nest into a stacker slot.""" + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + + target: PlateHolder + if site == "smallest": + target = self._available_sites(plate)[0] + elif site == "random": + target = random.choice(self._available_sites(plate)) + elif isinstance(site, PlateHolder): + if site not in self._available_sites(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + target = site + else: + raise ValueError(f"Invalid site: {site}") + + await self.retrieval.store_plate(plate, target) + plate.unassign() + target.assign_child_resource(plate) + + def serialize(self) -> dict: + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } From 494ceb690a0866476ecbbaafb3e0927426a3165a Mon Sep 17 00:00:00 2001 From: rickwierenga <7keerrickendan1@gmail.com> Date: Mon, 15 Jun 2026 10:31:50 -0700 Subject: [PATCH 02/23] feat(automated_retrieval): multi-tray support; use it for TundraStore's two nests Add an optional 0-based `tray` argument to AutomatedRetrieval.fetch_plate_to_ loading_tray / store_plate (and the backend abstract methods). `tray=None` selects the device default, so all existing callers are unaffected. - Single-tray devices (Cytomat, Heraeus, Liconic) validate via the new ensure_single_tray() helper (accept None/0, reject others). - TundraStore maps tray i -> device nest i+1; the frontend now models both nests as TundraStore.nests[0|1] with per-tray plate tracking (fetch_plate_to_nest / take_in_plate take a `tray`). Co-Authored-By: Claude Opus 4.8 --- .../automated_retrieval/__init__.py | 2 +- .../automated_retrieval.py | 27 ++++++-- .../automated_retrieval/backend.py | 34 ++++++++-- .../automated_retrieval/chatterbox.py | 9 +-- pylabrobot/highres/tundrastore/backend.py | 29 +++++--- .../highres/tundrastore/backend_tests.py | 12 ++++ pylabrobot/highres/tundrastore/chatterbox.py | 8 +-- pylabrobot/highres/tundrastore/tundrastore.py | 68 +++++++++++++------ pylabrobot/liconic/backend.py | 11 ++- pylabrobot/thermo_fisher/cytomat/backend.py | 11 ++- .../thermo_fisher/cytomat/heraeus_backend.py | 11 ++- 11 files changed, 166 insertions(+), 56 deletions(-) diff --git a/pylabrobot/capabilities/automated_retrieval/__init__.py b/pylabrobot/capabilities/automated_retrieval/__init__.py index b4bd3544909..5ddc8c2a5db 100644 --- a/pylabrobot/capabilities/automated_retrieval/__init__.py +++ b/pylabrobot/capabilities/automated_retrieval/__init__.py @@ -1,2 +1,2 @@ from .automated_retrieval import AutomatedRetrieval -from .backend import AutomatedRetrievalBackend +from .backend import AutomatedRetrievalBackend, ensure_single_tray diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py index 5f7ccd45c57..631a96bb6c6 100644 --- a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -1,3 +1,5 @@ +from typing import Optional + from pylabrobot.capabilities.capability import Capability, need_capability_ready from pylabrobot.resources import Plate, PlateHolder @@ -15,14 +17,27 @@ def __init__(self, backend: AutomatedRetrievalBackend): self.backend: AutomatedRetrievalBackend = backend @need_capability_ready - async def fetch_plate_to_loading_tray(self, plate: Plate): - """Retrieve a plate from storage and place it on the loading tray.""" - await self.backend.fetch_plate_to_loading_tray(plate) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + """Retrieve a plate from storage and place it on a loading tray. + + Args: + plate: The plate to retrieve. + tray: 0-based index of the loading tray to deliver to. ``None`` selects the + device's default tray (single-tray devices only accept ``None``/``0``). + """ + await self.backend.fetch_plate_to_loading_tray(plate, tray=tray) @need_capability_ready - async def store_plate(self, plate: Plate, site: PlateHolder): - """Store a plate from the loading tray into the given site.""" - await self.backend.store_plate(plate, site) + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + """Store a plate from a loading tray into the given site. + + Args: + plate: The plate to store. + site: The destination storage site. + tray: 0-based index of the loading tray the plate is on. ``None`` selects + the device's default tray. + """ + await self.backend.store_plate(plate, site, tray=tray) async def _on_stop(self): await super()._on_stop() diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py index 5ad8179d946..5aae5d93a6c 100644 --- a/pylabrobot/capabilities/automated_retrieval/backend.py +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -1,16 +1,42 @@ from abc import ABCMeta, abstractmethod +from typing import Optional from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources import Plate, PlateHolder +def ensure_single_tray(tray: Optional[int]) -> None: + """Guard for backends with exactly one loading tray. + + Raises ``ValueError`` if ``tray`` is anything other than ``None`` (default) or + ``0`` (the only tray). + """ + if tray not in (None, 0): + raise ValueError(f"This device has a single loading tray; got tray={tray}. Use None or 0.") + + class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for automated plate retrieval/storage devices.""" @abstractmethod - async def fetch_plate_to_loading_tray(self, plate: Plate): - """Retrieve a plate from storage and place it on the loading tray.""" + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + """Retrieve a plate from storage and place it on a loading tray. + + Args: + plate: The plate to retrieve. + tray: 0-based index of the loading tray to deliver the plate to. ``None`` + selects the device's default tray. Devices with a single loading tray + accept ``None``/``0`` and reject any other value (see + :func:`ensure_single_tray`). + """ @abstractmethod - async def store_plate(self, plate: Plate, site: PlateHolder): - """Store a plate from the loading tray into the given site.""" + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + """Store a plate from a loading tray into the given site. + + Args: + plate: The plate to store. + site: The destination storage site. + tray: 0-based index of the loading tray the plate is currently on. ``None`` + selects the device's default tray. + """ diff --git a/pylabrobot/capabilities/automated_retrieval/chatterbox.py b/pylabrobot/capabilities/automated_retrieval/chatterbox.py index d1ac1c8d463..80320e25c81 100644 --- a/pylabrobot/capabilities/automated_retrieval/chatterbox.py +++ b/pylabrobot/capabilities/automated_retrieval/chatterbox.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.plate import Plate @@ -11,8 +12,8 @@ class AutomatedRetrievalChatterboxBackend(AutomatedRetrievalBackend): """Chatterbox backend for device-free testing.""" - async def fetch_plate_to_loading_tray(self, plate: Plate): - logger.info("Fetching plate %s to loading tray.", plate.name) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + logger.info("Fetching plate %s to loading tray %s.", plate.name, tray) - async def store_plate(self, plate: Plate, site: PlateHolder): - logger.info("Storing plate %s at site %s.", plate.name, site.name) + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + logger.info("Storing plate %s at site %s (tray %s).", plate.name, site.name, tray) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index ba666f6f987..566141e0736 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -53,11 +53,11 @@ class TundraStoreBackend( and a (stacker, slot). The low-level :meth:`pick` / :meth:`place` take those three indices directly. - Note: the TundraStore has two nests. Mapping PyLabRobot's single - loading-tray-centric :class:`AutomatedRetrieval` capability onto two nests is - an open design question; for now the capability methods use - :attr:`loading_tray_nest` (default 1). Use :meth:`pick` / :meth:`place` - directly to address a specific nest. + The TundraStore has two nests. They are exposed through the multi-tray + :class:`AutomatedRetrieval` capability: its ``tray`` argument is a 0-based nest + index (tray ``i`` -> device nest ``i + 1``), and ``tray=None`` selects + :attr:`loading_tray_nest`. :meth:`pick` / :meth:`place` address a nest by its + 1-based device number directly. """ @dataclass @@ -97,6 +97,7 @@ def __init__( self._read_timeout = read_timeout self._motion_timeout = motion_timeout self.loading_tray_nest = loading_tray_nest + self.num_nests = 2 self._command_lock = asyncio.Lock() # stacker/slot lookup for the AutomatedRetrieval capability, built from racks. self._site_locations: Dict[str, Tuple[int, int]] = {} @@ -345,16 +346,26 @@ def _locate(self, site: PlateHolder) -> Tuple[int, int]: raise ValueError(f"Site '{site.name}' is not a known stacker slot; call set_racks() first.") return self._site_locations[site.name] - async def fetch_plate_to_loading_tray(self, plate: Plate): + def _nest_for_tray(self, tray: Optional[int]) -> int: + """Map a 0-based capability tray index to a 1-based device nest number. + + ``None`` selects :attr:`loading_tray_nest` (the configured default nest).""" + if tray is None: + return self.loading_tray_nest + if not 0 <= tray < self.num_nests: + raise ValueError(f"TundraStore has trays 0..{self.num_nests - 1}; got tray={tray}.") + return tray + 1 + + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): site = plate.parent if not isinstance(site, PlateHolder): raise ValueError(f"Plate '{plate.name}' is not in a stacker slot.") stacker, slot = self._locate(site) - await self.pick(stacker, slot, self.loading_tray_nest) + await self.pick(stacker, slot, self._nest_for_tray(tray)) - async def store_plate(self, plate: Plate, site: PlateHolder): + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): stacker, slot = self._locate(site) - await self.place(stacker, slot, self.loading_tray_nest) + await self.place(stacker, slot, self._nest_for_tray(tray)) # --- TemperatureController capability ------------------------------------- diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 5dc7eb7cbed..2f42a35af1b 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -154,6 +154,18 @@ async def test_pick_formats_command(self): await self.backend.pick(3, 12, 1) self.assertEqual(self.socket.written, ["pick 3 12 1"]) + def test_tray_maps_to_nest(self): + # 0-based capability tray -> 1-based device nest; None uses the default. + self.assertEqual(self.backend._nest_for_tray(None), 1) + self.assertEqual(self.backend._nest_for_tray(0), 1) + self.assertEqual(self.backend._nest_for_tray(1), 2) + with self.assertRaises(ValueError): + self.backend._nest_for_tray(2) + + def test_default_tray_follows_loading_tray_nest(self): + backend = TundraStoreBackend(host="10.253.253.253", loading_tray_nest=2) + self.assertEqual(backend._nest_for_tray(None), 2) + async def test_set_humidity_unsupported(self): with self.assertRaises(NotImplementedError): await self.backend.set_humidity(0.5) diff --git a/pylabrobot/highres/tundrastore/chatterbox.py b/pylabrobot/highres/tundrastore/chatterbox.py index 378000dae2c..2a362acaa3c 100644 --- a/pylabrobot/highres/tundrastore/chatterbox.py +++ b/pylabrobot/highres/tundrastore/chatterbox.py @@ -40,11 +40,11 @@ async def pick(self, stacker: int, slot: int, nest: int): async def place(self, stacker: int, slot: int, nest: int): logger.info("[tundrastore] place stacker=%d slot=%d nest=%d", stacker, slot, nest) - async def fetch_plate_to_loading_tray(self, plate: Plate): - logger.info("[tundrastore] fetch plate %s to loading tray", plate.name) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + logger.info("[tundrastore] fetch plate %s to loading tray %s", plate.name, tray) - async def store_plate(self, plate: Plate, site: PlateHolder): - logger.info("[tundrastore] store plate %s at site %s", plate.name, site.name) + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + logger.info("[tundrastore] store plate %s at site %s (tray %s)", plate.name, site.name, tray) @property def supports_active_cooling(self) -> bool: diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/tundrastore/tundrastore.py index a14ecb10475..9c5c24001a4 100644 --- a/pylabrobot/highres/tundrastore/tundrastore.py +++ b/pylabrobot/highres/tundrastore/tundrastore.py @@ -27,10 +27,10 @@ class TundraStore(Resource, Device): """HighRes Biosolutions TundraStore refrigerated plate store. Each rack is a *stacker* (a vertical column of plate slots); plates enter and - leave through a *nest* (transfer station). The store has two nests, but - mapping PyLabRobot's single-loading-tray :class:`AutomatedRetrieval` model - onto two nests is still an open design decision, so for now this frontend - drives a single configurable nest (see ``loading_tray_nest`` on the backend). + leave through one of the device's *nests* (transfer stations). The store has + two nests, modeled here as two loading trays — :attr:`nests` ``[0]`` and + ``[1]`` — addressed by the ``tray`` argument of the :class:`AutomatedRetrieval` + capability (0-based). ``tray=None`` uses :attr:`default_tray`. """ def __init__( @@ -38,7 +38,8 @@ def __init__( name: str, driver: TundraStoreBackend, racks: List[PlateCarrier], - loading_tray_location: Coordinate, + nest_locations: List[Coordinate], + default_tray: int = 0, size_x: float = 0, size_y: float = 0, size_z: float = 0, @@ -46,6 +47,13 @@ def __init__( category: Optional[str] = "plate_store", model: Optional[str] = "TundraStore", ): + """ + Args: + racks: Storage racks; rack *i* maps to device stacker ``i + 1``. + nest_locations: One :class:`Coordinate` per transfer nest (the device has + two). ``nest_locations[i]`` is the location of nest/tray ``i``. + default_tray: 0-based nest used when a ``tray`` argument is omitted. + """ Resource.__init__( self, name=name, @@ -58,11 +66,15 @@ def __init__( ) Device.__init__(self, driver=driver) self.driver: TundraStoreBackend = driver + self.default_tray = default_tray - self.loading_tray = PlateHolder( - name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 - ) - self.assign_child_resource(self.loading_tray, location=loading_tray_location) + self.nests: List[PlateHolder] = [] + for i, location in enumerate(nest_locations): + nest = PlateHolder( + name=f"{name}_nest_{i + 1}", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(nest, location=location) + self.nests.append(nest) self._racks = racks for rack in self._racks: @@ -77,6 +89,12 @@ def __init__( def racks(self) -> List[PlateCarrier]: return self._racks + def _tray_index(self, tray: Optional[int]) -> int: + idx = self.default_tray if tray is None else tray + if not 0 <= idx < len(self.nests): + raise ValueError(f"'{self.name}' has trays 0..{len(self.nests) - 1}; got tray={idx}.") + return idx + async def setup(self, backend_params: Optional[BackendParams] = None): await super().setup(backend_params=backend_params) await self.driver.set_racks(self._racks) @@ -105,20 +123,31 @@ def height(p: Plate) -> float: raise NoFreeSiteError(f"No free site in '{self.name}' for plate '{plate.name}'") return sorted(available, key=lambda s: s.get_size_z()) - async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: - """Retrieve a stored plate and place it on the loading-tray nest.""" + async def fetch_plate_to_nest(self, plate_name: str, tray: Optional[int] = None) -> Plate: + """Retrieve a stored plate and place it on a nest (default :attr:`default_tray`).""" + idx = self._tray_index(tray) site = self.get_site_by_plate_name(plate_name) plate = cast(Plate, site.resource) - await self.retrieval.fetch_plate_to_loading_tray(plate) + await self.retrieval.fetch_plate_to_loading_tray(plate, tray=idx) plate.unassign() - self.loading_tray.assign_child_resource(plate) + self.nests[idx].assign_child_resource(plate) return plate - async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): - """Store the plate currently on the loading-tray nest into a stacker slot.""" - plate = cast(Plate, self.loading_tray.resource) + async def take_in_plate( + self, + site: Union[PlateHolder, Literal["random", "smallest"]], + tray: Optional[int] = None, + ): + """Store the plate currently on a nest into a stacker slot. + + Args: + site: Destination slot, or ``"smallest"`` / ``"random"`` to auto-select. + tray: Which nest the plate is on (default :attr:`default_tray`). + """ + idx = self._tray_index(tray) + plate = cast(Plate, self.nests[idx].resource) if plate is None: - raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + raise ResourceNotFoundError(f"No plate on nest {idx} of '{self.name}'") target: PlateHolder if site == "smallest": @@ -132,7 +161,7 @@ async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smalle else: raise ValueError(f"Invalid site: {site}") - await self.retrieval.store_plate(plate, target) + await self.retrieval.store_plate(plate, target, tray=idx) plate.unassign() target.assign_child_resource(plate) @@ -143,5 +172,6 @@ def serialize(self) -> dict: **Device.serialize(self), **Resource.serialize(self), "racks": [rack.serialize() for rack in self._racks], - "loading_tray_location": serialize(self.loading_tray.location), + "nest_locations": [serialize(nest.location) for nest in self.nests], + "default_tray": self.default_tray, } diff --git a/pylabrobot/liconic/backend.py b/pylabrobot/liconic/backend.py index ddd0cbcd581..53f8f3efb6b 100644 --- a/pylabrobot/liconic/backend.py +++ b/pylabrobot/liconic/backend.py @@ -13,7 +13,10 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.automated_retrieval.backend import ( + AutomatedRetrievalBackend, + ensure_single_tray, +) from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend @@ -149,7 +152,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + ensure_single_tray(tray) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -172,7 +176,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate): n, ) - async def store_plate(self, plate: Plate, site: PlateHolder): + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + ensure_single_tray(tray) m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) diff --git a/pylabrobot/thermo_fisher/cytomat/backend.py b/pylabrobot/thermo_fisher/cytomat/backend.py index f6af01f4695..cda7c74238d 100644 --- a/pylabrobot/thermo_fisher/cytomat/backend.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -12,7 +12,10 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.automated_retrieval.backend import ( + AutomatedRetrievalBackend, + ensure_single_tray, +) from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend @@ -122,7 +125,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + ensure_single_tray(tray) logger.info( "[Cytomat %s %s] fetch plate to loading tray: plate='%s'", self.model.value, @@ -133,7 +137,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate): assert isinstance(site, PlateHolder) await self.action_storage_to_transfer(site) - async def store_plate(self, plate: Plate, site: PlateHolder): + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + ensure_single_tray(tray) logger.info( "[Cytomat %s %s] store plate: plate='%s', site='%s'", self.model.value, diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index f9a07687c68..e47ba2cf705 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -12,7 +12,10 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.automated_retrieval.backend import ( + AutomatedRetrievalBackend, + ensure_single_tray, +) from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend @@ -121,7 +124,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + ensure_single_tray(tray) logger.info("[Heraeus %s] fetch plate to loading tray: plate='%s'", self.io.port, plate.name) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -132,7 +136,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate): await self._wait_ready() await self._send_command("ST 1903") - async def store_plate(self, plate: Plate, site: PlateHolder): + async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + ensure_single_tray(tray) logger.info( "[Heraeus %s] store plate: plate='%s', site='%s'", self.io.port, plate.name, site.name ) From c4e6a512f5336468906423fff4ce72c906feb714 Mon Sep 17 00:00:00 2001 From: rickwierenga <7keerrickendan1@gmail.com> Date: Mon, 15 Jun 2026 22:55:34 -0700 Subject: [PATCH 03/23] feat(highres/tundrastore): settings client + robust pick fault handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TundraStoreSettings: client-side dataclass over the device's ~545-key settings file, with typed accessors and named properties (height-detect, beam-break, stacker bases, nest heights/inputs, plate defaults). Loadable from the device (backend.request_settings()) or JSON. - pick() now classifies failures without performing hidden motion: - PlateNotFoundError when a slot is empty ("No plate detected") and the store retracted cleanly (the normal empty-slot outcome at any non-top slot). - TundraStoreFault when the machine was left unsafe (spatula extended / unhomed) — e.g. an empty TOP slot, where the firmware can't complete its safe-travel retract. homedstatus reports homed even while stuck, so the firmware's "unsafe for rotation" signal is used, not just is_homed(). - recover(): explicit retract-spatula + re-home, for use after a fault. Hardware-validated: empty middle-slot pick -> graceful "No plate detected"; empty top-slot pick -> fault + recover() restores a homed state. --- pylabrobot/highres/tundrastore/__init__.py | 8 +- pylabrobot/highres/tundrastore/backend.py | 66 ++++++++- .../highres/tundrastore/backend_tests.py | 117 ++++++++++++++- pylabrobot/highres/tundrastore/errors.py | 43 ++++++ pylabrobot/highres/tundrastore/settings.py | 136 ++++++++++++++++++ 5 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 pylabrobot/highres/tundrastore/settings.py diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index e9056b835bb..8768f7f971a 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -1,7 +1,13 @@ from .backend import TundraStoreBackend from .chatterbox import TundraStoreChatterboxBackend from .constants import DoorState, NestState -from .errors import TundraStoreAbortedError, TundraStoreError +from .errors import ( + PlateNotFoundError, + TundraStoreAbortedError, + TundraStoreError, + TundraStoreFault, +) +from .settings import TundraStoreSettings from .standard import ( DoorStatus, EnvironmentParameter, diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 566141e0736..e6bd15ce5af 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -20,7 +20,14 @@ DoorState, NestState, ) -from .errors import TundraStoreAbortedError, TundraStoreError +from .errors import ( + PlateNotFoundError, + TundraStoreAbortedError, + TundraStoreError, + TundraStoreFault, + left_unsafe, +) +from .settings import TundraStoreSettings from .standard import ( DoorStatus, EnvironmentParameter, @@ -288,6 +295,16 @@ async def get_stacker_dimensions(self) -> List[StackerDimensions]: continue return dims + async def request_settings(self, search: str = "") -> TundraStoreSettings: + """Read the device's settings file (``NAME = value`` pairs) into a + :class:`TundraStoreSettings`. Pass ``search`` to filter by substring.""" + command = "settings" + (f" {search}" if search else "") + lines = await self.send_command(command, timeout=self._read_timeout) + version = await self.request_version() + return TundraStoreSettings.from_lines( + lines, serial=version.serial_number, firmware=version.firmware_version + ) + async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: """Scan a stacker (or a single slot) for barcodes. @@ -309,9 +326,52 @@ async def home(self): :class:`TundraStoreError` ("Unable to close all doors").""" await self.send_command("home", timeout=self._motion_timeout) + async def recover(self) -> bool: + """Bring the machine back to a homed state after a motion fault. + + A faulted command (e.g. an empty-slot ``pick`` at a tight top slot) can + leave the spatula extended and the machine unhomed. This retracts the + spatula (``spatulaout``) and re-homes, retrying a few times. Returns + ``True`` if the machine ends up homed. + """ + for _ in range(3): + if await self.is_homed(): + return True + for command in ("enable", "spatulaout"): + try: + await self.send_command(command, timeout=self._motion_timeout) + except TundraStoreError: + pass + try: + await self.send_command("home", timeout=self._motion_timeout) + except TundraStoreError: + pass + return await self.is_homed() + async def pick(self, stacker: int, slot: int, nest: int): - """Retrieve a plate from ``(stacker, slot)`` to ``nest``.""" - await self.send_command(f"pick {stacker} {slot} {nest}", timeout=self._motion_timeout) + """Retrieve a plate from ``(stacker, slot)`` to ``nest``. + + On failure the error is classified; no automatic motion is performed: + + - :class:`PlateNotFoundError` — the slot was empty ("No plate detected") + and the store retracted cleanly; the machine is safe to keep using. + - :class:`TundraStoreFault` — the machine was left unsafe (spatula extended + / unhomed), e.g. an empty *top* slot where the firmware can't complete its + safe-travel retract. Call :meth:`recover` before any further motion. + + Note: ``homedstatus`` reports homed even when the spatula is stuck extended + at a top slot, so the firmware's own "unsafe for rotation" signal is used + (not just :meth:`is_homed`) to detect that case. + """ + command = f"pick {stacker} {slot} {nest}" + try: + await self.send_command(command, timeout=self._motion_timeout) + except TundraStoreError as exc: + if left_unsafe(exc.error_lines) or not await self.is_homed(): + raise TundraStoreFault(command, exc.error_lines) from exc + if any("no plate detected" in line.lower() for line in exc.error_lines): + raise PlateNotFoundError(command, exc.error_lines) from exc + raise async def place(self, stacker: int, slot: int, nest: int): """Place the plate at ``nest`` into ``(stacker, slot)``.""" diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 2f42a35af1b..624d073f738 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -3,7 +3,11 @@ from pylabrobot.highres.tundrastore.backend import TundraStoreBackend from pylabrobot.highres.tundrastore.constants import DoorState, NestState -from pylabrobot.highres.tundrastore.errors import TundraStoreError +from pylabrobot.highres.tundrastore.errors import ( + PlateNotFoundError, + TundraStoreError, + TundraStoreFault, +) # Real responses captured from a TundraStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. @@ -171,5 +175,116 @@ async def test_set_humidity_unsupported(self): await self.backend.set_humidity(0.5) +def _ok(command: str, cid: int) -> List[str]: + return [f"ACK! {command} {cid}", f"OK! {command} {cid}"] + + +class ScriptedSocket: + """Replays an ordered (expected_command, response_lines) script, asserting the + exact command sequence — used to verify multi-step recovery flows.""" + + def __init__(self, script): + self.script = list(script) + self.i = 0 + self.commands: List[str] = [] + self._queue: List[str] = [] + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes, timeout=None): + command = data.decode("ascii").rstrip("\r\n") + self.commands.append(command) + expected, lines = self.script[self.i] + self.i += 1 + assert command == expected, f"expected {expected!r}, got {command!r}" + self._queue = list(lines) + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + return self._queue.pop(0).encode("ascii") + b"\r\n" + + +class TundraStoreRecoveryTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.backend = TundraStoreBackend(host="10.253.253.253") + + async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): + # The store reports "No plate detected" and stays homed (graceful empty). + empty = [ + "ACK! pick 5 12 1 50", + "Error 1: (00:00:01) 50: No plate detected", + "ERROR! pick 5 12 1 50", + ] + sock = ScriptedSocket( + [ + ("pick 5 12 1", empty), + ("homedstatus", ["ACK! homedstatus 51", "homed", "OK! homedstatus 51"]), + ] + ) + self.backend.io = sock # type: ignore[assignment] + with self.assertRaises(PlateNotFoundError): + await self.backend.pick(5, 12, 1) + # classified by state, no recovery motion issued + self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus"]) + + async def test_top_slot_stuck_raises_fault_despite_homed_lie(self): + # Empty TOP slot: "No plate detected" but the spatula is left extended and + # the firmware reports "unsafe for rotation" while homedstatus still says + # homed. The "unsafe" signal must win -> TundraStoreFault (no homedstatus + # query needed, the signature short-circuits). + stuck = [ + "ACK! pick 5 24 1 60", + "Error 1: 60: No plate detected", + "Error 2: 60: Z height is unsafe for rotation, check machine", + "ERROR! pick 5 24 1 60", + ] + sock = ScriptedSocket([("pick 5 24 1", stuck)]) + self.backend.io = sock # type: ignore[assignment] + with self.assertRaises(TundraStoreFault): + await self.backend.pick(5, 24, 1) + self.assertEqual(sock.commands, ["pick 5 24 1"]) + + async def test_dehomed_pick_raises_fault(self): + # An error with no "unsafe" signature but the machine reports unhomed. + fault = [ + "ACK! pick 5 24 1 70", + "Error 1: 70: motor fault", + "ERROR! pick 5 24 1 70", + ] + sock = ScriptedSocket( + [ + ("pick 5 24 1", fault), + ("homedstatus", ["ACK! homedstatus 71", "not homed", "OK! homedstatus 71"]), + ] + ) + self.backend.io = sock # type: ignore[assignment] + with self.assertRaises(TundraStoreFault): + await self.backend.pick(5, 24, 1) + self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) + + async def test_recover_retracts_and_rehomes_when_unhomed(self): + sock = ScriptedSocket( + [ + ("homedstatus", ["ACK! homedstatus 1", "not homed", "OK! homedstatus 1"]), + ("enable", _ok("enable", 2)), + ("spatulaout", _ok("spatulaout", 3)), + ("home", _ok("home", 4)), + ("homedstatus", ["ACK! homedstatus 5", "homed", "OK! homedstatus 5"]), + ] + ) + self.backend.io = sock # type: ignore[assignment] + self.assertTrue(await self.backend.recover()) + self.assertEqual(sock.commands, ["homedstatus", "enable", "spatulaout", "home", "homedstatus"]) + + async def test_recover_returns_true_when_homed(self): + sock = ScriptedSocket([("homedstatus", ["ACK! homedstatus 1", "homed", "OK! homedstatus 1"])]) + self.backend.io = sock # type: ignore[assignment] + self.assertTrue(await self.backend.recover()) + self.assertEqual(sock.commands, ["homedstatus"]) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/highres/tundrastore/errors.py b/pylabrobot/highres/tundrastore/errors.py index faaded1cbe2..11d85aad3e5 100644 --- a/pylabrobot/highres/tundrastore/errors.py +++ b/pylabrobot/highres/tundrastore/errors.py @@ -23,3 +23,46 @@ class TundraStoreAbortedError(Exception): def __init__(self, command: str): self.command = command super().__init__(f"'{command}' was aborted") + + +class PlateNotFoundError(TundraStoreError): + """A pick found no plate in the target slot ("No plate detected"). + + This is the normal *empty slot* outcome: the store's height detector reports + the absence and the machine stays homed and operational — not a fault. + Contrast with :class:`TundraStoreFault` (the machine de-homed). Note that an + empty *top* slot raises a fault instead, because the firmware can't complete + its safe-travel retract from the topmost position. + """ + + +class TundraStoreFault(TundraStoreError): + """A motion command faulted and left the machine UNHOMED/extended. + + The canonical trigger is picking an empty *top* slot. The machine is not + usable until recovered — call :meth:`TundraStoreBackend.recover` (retract the + spatula and re-home) before issuing further motion. + """ + + def __init__(self, command: str, error_lines: List[str]): + super().__init__(command, error_lines) + self.args = (f"{self.args[0]}; machine is unsafe — call recover()",) + + +# Error-stack substrings meaning the spatula was left extended / unsafe to move, +# even when ``homedstatus`` still reports homed (it does exactly that when the +# spatula is stuck extended at a top slot). Recover before any further motion. +_UNSAFE_SIGNATURES = ( + "unsafe for rotation", + "safe travel position", + "crash occurred", + "sensor was tripped", + "machine must be homed", +) + + +def left_unsafe(error_lines: List[str]) -> bool: + """Whether an error stack indicates the machine was left unsafe (spatula + extended / unhomed), requiring :meth:`TundraStoreBackend.recover`.""" + blob = " ".join(error_lines).lower() + return any(sig in blob for sig in _UNSAFE_SIGNATURES) diff --git a/pylabrobot/highres/tundrastore/settings.py b/pylabrobot/highres/tundrastore/settings.py new file mode 100644 index 00000000000..e4d714a1492 --- /dev/null +++ b/pylabrobot/highres/tundrastore/settings.py @@ -0,0 +1,136 @@ +import json +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass +class TundraStoreSettings: + """Client-side view of the TundraStore's on-device settings file. + + The device exposes ~545 calibration/config keys via the ``settings`` command + as ``NAME = value`` text. Values are kept verbatim as strings in :attr:`raw` + (the device's own representation); use the typed accessors and named + properties below for parsed values. Addresses like ``2004007`` are + Copley/digital-IO register addresses a sensor is wired to. + """ + + raw: Dict[str, str] = field(default_factory=dict) + serial_number: Optional[str] = None + firmware_version: Optional[str] = None + + # --- generic typed access ------------------------------------------------- + + def get(self, key: str) -> Optional[str]: + return self.raw.get(key) + + def get_float(self, key: str) -> Optional[float]: + v = self.raw.get(key) + try: + return float(v) if v is not None else None + except ValueError: + return None + + def get_int(self, key: str) -> Optional[int]: + v = self.raw.get(key) + try: + return int(v) if v is not None else None + except ValueError: + return None + + # --- named accessors for settings the driver reasons about ---------------- + + @property + def height_detect_base(self) -> Optional[float]: + return self.get_float("HEIGHT_DETECT_BASE") + + @property + def height_detect_positive_adjustment(self) -> Optional[float]: + return self.get_float("HEIGHT_DETECT_POSITIVE_ADJUSTMENT") + + @property + def height_detect_negative_adjustment(self) -> Optional[float]: + return self.get_float("HEIGHT_DETECT_NEGATIVE_ADJUSTMENT") + + @property + def height_detect_input_number(self) -> Optional[int]: + return self.get_int("HEIGHT_DETECT_INPUT_NUMBER") + + @property + def height_detect_enable_address(self) -> Optional[int]: + return self.get_int("HEIGHT_DETECT_ENABLE_ADDRESS") + + @property + def spatula_beam_break_height(self) -> Optional[float]: + return self.get_float("SPATULA_BEAM_BREAK_HEIGHT") + + @property + def default_plate_height(self) -> Optional[float]: + return self.get_float("DEF_PLATE_HEIGHT") + + @property + def default_plate_thickness(self) -> Optional[float]: + return self.get_float("DEF_PLATE_THICKNESS") + + def stacker_base(self, bank: int) -> Optional[float]: + """Z height of stacker base ``bank`` (slot 1 sits here).""" + return self.get_float(f"STACKER_BASE_{bank}") + + def nest_height(self, nest: int) -> Optional[float]: + return self.get_float(f"NEST_{nest}_HEIGHT") + + def nest_sense_input(self, nest: int) -> Optional[int]: + """Digital-IO address of nest ``nest``'s plate-presence sensor.""" + return self.get_int(f"NEST_{nest}_SENSE_INPUT") + + # --- (de)serialization ---------------------------------------------------- + + @classmethod + def from_dict( + cls, data: Dict, serial: Optional[str] = None, firmware: Optional[str] = None + ) -> "TundraStoreSettings": + return cls( + raw={str(k): str(v) for k, v in data.items()}, + serial_number=serial, + firmware_version=firmware, + ) + + @classmethod + def from_lines( + cls, lines, serial: Optional[str] = None, firmware: Optional[str] = None + ) -> "TundraStoreSettings": + """Parse the device's ``NAME = value`` settings lines.""" + raw: Dict[str, str] = {} + for line in lines: + if "=" in line: + key, _, value = line.partition("=") + raw[key.strip()] = value.strip() + return cls.from_dict(raw, serial, firmware) + + @classmethod + def from_json(cls, path: str) -> "TundraStoreSettings": + with open(path) as f: + obj = json.load(f) + if "settings" in obj: + return cls.from_dict(obj["settings"], obj.get("_serial"), obj.get("_firmware")) + return cls.from_dict(obj) + + def to_json(self, path: str) -> None: + with open(path, "w") as f: + json.dump( + { + "_serial": self.serial_number, + "_firmware": self.firmware_version, + "settings": self.raw, + }, + f, + indent=2, + ) + + def __len__(self) -> int: + return len(self.raw) + + def __getitem__(self, key: str) -> str: + return self.raw[key] + + def __contains__(self, key: str) -> bool: + return key in self.raw From cfc022c4cbcc1a44f317b904f239695d39abe7ac Mon Sep 17 00:00:00 2001 From: rickwierenga <7keerrickendan1@gmail.com> Date: Mon, 15 Jun 2026 23:18:55 -0700 Subject: [PATCH 04/23] feat(highres/tundrastore): presence query methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw status-command calls with typed methods: - spatula_request_is_holding() -> bool - nest_request_is_holding(nest) -> bool (occupied nest reports UNKNOWN) - probe_presence(stacker, slot, to_nest=1) -> bool — sense a stacker slot by attempting a pick (graceful empty at non-top slots); a found plate is moved to to_nest as a side effect. --- pylabrobot/highres/tundrastore/backend.py | 27 ++++++++++++++++++- .../highres/tundrastore/backend_tests.py | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index e6bd15ce5af..f8bfdd986f8 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -241,11 +241,36 @@ async def request_nest_status(self) -> NestStatus: nests[nest] = NestState.UNKNOWN return NestStatus(nests=nests) - async def request_plate_on_spatula(self) -> bool: + async def spatula_request_is_holding(self) -> bool: """Whether a plate is currently held on the spatula (``platestatus``).""" lines = await self.send_command("platestatus") return not any("NO_PLATE" in line for line in lines) + async def nest_request_is_holding(self, nest: int) -> bool: + """Whether a plate is present on ``nest`` (per its plate sensor). + + Note: this unit reports an occupied nest as ``UNKNOWN`` rather than + ``OCCUPIED``; anything other than ``CLEAR`` counts as holding. + """ + state = (await self.request_nest_status()).nests.get(nest) + return state is not None and state is not NestState.CLEAR + + async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> bool: + """Probe whether a plate is present in ``(stacker, slot)`` by attempting a + pick. Returns ``True`` if a plate was there, ``False`` if the slot is empty. + + SIDE EFFECT: a plate that is found is moved to ``to_nest`` (the only way to + sense a stacker slot is to pick it). Only safe for non-top slots, where an + empty pick is graceful; the top slot (24) faults when empty — see + :meth:`pick`. For nests, use :meth:`nest_request_is_holding` instead (a + non-destructive sensor read). + """ + try: + await self.pick(stacker, slot, to_nest) + return True + except PlateNotFoundError: + return False + async def request_environment(self) -> Dict[str, EnvironmentParameter]: """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 624d073f738..6b88567c8d6 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -118,7 +118,7 @@ async def test_nest_status(self): self.assertEqual(status.nests, {1: NestState.CLEAR, 2: NestState.CLEAR}) async def test_plate_on_spatula(self): - self.assertFalse(await self.backend.request_plate_on_spatula()) + self.assertFalse(await self.backend.spatula_request_is_holding()) async def test_environment_parsing(self): env = await self.backend.request_environment() From b2aadddf54f6329099f4a611c460ccef9788134d Mon Sep 17 00:00:00 2001 From: rickwierenga <7keerrickendan1@gmail.com> Date: Tue, 16 Jun 2026 12:00:27 -0700 Subject: [PATCH 05/23] fix(highres/tundrastore): recover() no-op'd on the homedstatus lie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recover() short-circuited on is_homed(), but homedstatus reports homed even while the spatula is stuck extended in a stacker after a faulted top-slot pick — so recover() returned True without retracting (left the machine stuck). - Add is_parked(): homed AND the slide (Y) axis retracted near home (a stuck spatula sits at the ~256mm slide-in depth). This is the real "safe to move" check; is_homed() alone is not. - recover() now ALWAYS issues enable -> spatulaout -> home and confirms via is_parked(), retrying a few times. Never trusts homedstatus. Hardware-validated: empty-pick fault -> is_homed()=True but is_parked()=False; recover() retracts Y 256->0 and re-homes. Also found the empty-pick fault affects the top *few* slots (23 faults too), not only slot 24. --- pylabrobot/highres/tundrastore/backend.py | 34 +++++++++--- .../highres/tundrastore/backend_tests.py | 53 +++++++++++++++---- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index f8bfdd986f8..48a67484938 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -105,6 +105,9 @@ def __init__( self._motion_timeout = motion_timeout self.loading_tray_nest = loading_tray_nest self.num_nests = 2 + # Slide (Y) below this is "retracted"; a spatula stuck in a stacker sits at + # the ~256mm slide-in depth, home is 0. Used by is_parked()/recover(). + self._retracted_y_max = 50.0 self._command_lock = asyncio.Lock() # stacker/slot lookup for the AutomatedRetrieval capability, built from racks. self._site_locations: Dict[str, Tuple[int, int]] = {} @@ -351,17 +354,30 @@ async def home(self): :class:`TundraStoreError` ("Unable to close all doors").""" await self.send_command("home", timeout=self._motion_timeout) + async def is_parked(self) -> bool: + """Whether the machine is genuinely safe to move: homed AND the spatula + retracted out of the carousel. + + Prefer this over :meth:`is_homed`. ``homedstatus`` reports homed even while + the spatula is stuck extended in a stacker after a faulted top-slot pick, so + it alone is not a safe-state check; this also verifies the slide (Y) axis is + near its home position (a stuck spatula sits at the ~256mm slide-in depth). + """ + if not await self.is_homed(): + return False + y = (await self.request_axis_positions()).get("Y axis") + return y is not None and abs(y) < self._retracted_y_max + async def recover(self) -> bool: - """Bring the machine back to a homed state after a motion fault. + """Retract the spatula and re-home after a motion fault. - A faulted command (e.g. an empty-slot ``pick`` at a tight top slot) can - leave the spatula extended and the machine unhomed. This retracts the - spatula (``spatulaout``) and re-homes, retrying a few times. Returns - ``True`` if the machine ends up homed. + A faulted command (e.g. an empty-slot ``pick`` in the top few slots) can + leave the spatula extended. This ALWAYS issues the retract (``spatulaout``) + + ``home`` — it does not trust ``homedstatus`` to decide whether recovery is + needed, because that reports homed even while the spatula is stuck extended. + Retries a few times. Returns ``True`` once :meth:`is_parked`. """ for _ in range(3): - if await self.is_homed(): - return True for command in ("enable", "spatulaout"): try: await self.send_command(command, timeout=self._motion_timeout) @@ -371,7 +387,9 @@ async def recover(self) -> bool: await self.send_command("home", timeout=self._motion_timeout) except TundraStoreError: pass - return await self.is_homed() + if await self.is_parked(): + return True + return False async def pick(self, stacker: int, slot: int, nest: int): """Retrieve a plate from ``(stacker, slot)`` to ``nest``. diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 6b88567c8d6..4397c504217 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -265,25 +265,58 @@ async def test_dehomed_pick_raises_fault(self): await self.backend.pick(5, 24, 1) self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) - async def test_recover_retracts_and_rehomes_when_unhomed(self): + def _homed(self, cid: int) -> List[str]: + return [f"ACK! homedstatus {cid}", "homed", f"OK! homedstatus {cid}"] + + def _status(self, cid: int, y: float) -> List[str]: + return [ + f"ACK! status {cid}", + "Carousel: 0.0", + f"Y axis: {y}", + "Z axis: 0.0", + f"OK! status {cid}", + ] + + async def test_is_parked_catches_the_homed_lie(self): + # homedstatus says homed, but the spatula is stuck extended (Y=256) -> NOT parked. + sock = ScriptedSocket([("homedstatus", self._homed(1)), ("status", self._status(2, 255.9999))]) + self.backend.io = sock # type: ignore[assignment] + self.assertFalse(await self.backend.is_parked()) + + async def test_recover_always_retracts_and_rehomes(self): + # recover() must issue retract+home even when homedstatus already says homed + # (the lie), then confirm parked via the slide position. sock = ScriptedSocket( [ - ("homedstatus", ["ACK! homedstatus 1", "not homed", "OK! homedstatus 1"]), - ("enable", _ok("enable", 2)), - ("spatulaout", _ok("spatulaout", 3)), - ("home", _ok("home", 4)), - ("homedstatus", ["ACK! homedstatus 5", "homed", "OK! homedstatus 5"]), + ("enable", _ok("enable", 1)), + ("spatulaout", _ok("spatulaout", 2)), + ("home", _ok("home", 3)), + ("homedstatus", self._homed(4)), + ("status", self._status(5, 0.0)), ] ) self.backend.io = sock # type: ignore[assignment] self.assertTrue(await self.backend.recover()) - self.assertEqual(sock.commands, ["homedstatus", "enable", "spatulaout", "home", "homedstatus"]) + self.assertEqual(sock.commands, ["enable", "spatulaout", "home", "homedstatus", "status"]) - async def test_recover_returns_true_when_homed(self): - sock = ScriptedSocket([("homedstatus", ["ACK! homedstatus 1", "homed", "OK! homedstatus 1"])]) + async def test_recover_retries_until_parked(self): + # First round still reads extended (homed-lie); recover retries and succeeds. + sock = ScriptedSocket( + [ + ("enable", _ok("enable", 1)), + ("spatulaout", _ok("spatulaout", 2)), + ("home", _ok("home", 3)), + ("homedstatus", self._homed(4)), + ("status", self._status(5, 255.9999)), # still extended -> retry + ("enable", _ok("enable", 6)), + ("spatulaout", _ok("spatulaout", 7)), + ("home", _ok("home", 8)), + ("homedstatus", self._homed(9)), + ("status", self._status(10, 0.0)), # now retracted + ] + ) self.backend.io = sock # type: ignore[assignment] self.assertTrue(await self.backend.recover()) - self.assertEqual(sock.commands, ["homedstatus"]) if __name__ == "__main__": From 3f820b6e04c5120ab7ecee517e7fb893a7ae9b29 Mon Sep 17 00:00:00 2001 From: rickwierenga <7keerrickendan1@gmail.com> Date: Tue, 16 Jun 2026 17:23:48 -0700 Subject: [PATCH 06/23] feat(highres/tundrastore): close_door option on pick/place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store re-seals its doors during every transfer (the firmware exposes no way to skip that — a 4th `place` arg errors on 3.0.0.119), so close_door controls the END state: close_door=False re-opens the doors after the move, leaving the carousel accessible for back-to-back ops. Defaults to True (sealed). One firmware-agnostic implementation (place/pick + conditional openalldoors). --- pylabrobot/highres/tundrastore/backend.py | 19 +++++++++++-- .../highres/tundrastore/backend_tests.py | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 48a67484938..3b22383fec3 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -391,9 +391,11 @@ async def recover(self) -> bool: return True return False - async def pick(self, stacker: int, slot: int, nest: int): + async def pick(self, stacker: int, slot: int, nest: int, close_door: bool = True): """Retrieve a plate from ``(stacker, slot)`` to ``nest``. + ``close_door=False`` re-opens the doors after the transfer (see :meth:`place`). + On failure the error is classified; no automatic motion is performed: - :class:`PlateNotFoundError` — the slot was empty ("No plate detected") @@ -415,10 +417,21 @@ async def pick(self, stacker: int, slot: int, nest: int): if any("no plate detected" in line.lower() for line in exc.error_lines): raise PlateNotFoundError(command, exc.error_lines) from exc raise + if not close_door: + await self.open_all_doors() + + async def place(self, stacker: int, slot: int, nest: int, close_door: bool = True): + """Place the plate at ``nest`` into ``(stacker, slot)``. - async def place(self, stacker: int, slot: int, nest: int): - """Place the plate at ``nest`` into ``(stacker, slot)``.""" + The store re-seals its doors as part of every transfer, so ``close_door`` + controls only the *end* state: with ``close_door=False`` the doors are + re-opened after the place, leaving the carousel accessible for a following + operation (handy when the cold environment doesn't matter). The default + leaves it sealed. + """ await self.send_command(f"place {stacker} {slot} {nest}", timeout=self._motion_timeout) + if not close_door: + await self.open_all_doors() async def open_all_doors(self): await self.send_command("openalldoors", timeout=self._motion_timeout) diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 4397c504217..fc7f0f6ad24 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -318,6 +318,34 @@ async def test_recover_retries_until_parked(self): self.backend.io = sock # type: ignore[assignment] self.assertTrue(await self.backend.recover()) + async def test_place_default_leaves_doors_sealed(self): + sock = ScriptedSocket([("place 2 5 1", _ok("place 2 5 1", 1))]) + self.backend.io = sock # type: ignore[assignment] + await self.backend.place(2, 5, 1) # close_door=True default + self.assertEqual(sock.commands, ["place 2 5 1"]) + + async def test_place_close_door_false_reopens(self): + sock = ScriptedSocket( + [ + ("place 2 5 1", _ok("place 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + self.backend.io = sock # type: ignore[assignment] + await self.backend.place(2, 5, 1, close_door=False) + self.assertEqual(sock.commands, ["place 2 5 1", "openalldoors"]) + + async def test_pick_close_door_false_reopens(self): + sock = ScriptedSocket( + [ + ("pick 2 5 1", _ok("pick 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + self.backend.io = sock # type: ignore[assignment] + await self.backend.pick(2, 5, 1, close_door=False) + self.assertEqual(sock.commands, ["pick 2 5 1", "openalldoors"]) + if __name__ == "__main__": unittest.main() From 60c395d00380da9560433b14ca02c1b5509e8332 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 18:18:59 -0700 Subject: [PATCH 07/23] Rename automated-retrieval `tray` parameter to `tray_index` Renames the new loading-tray/nest index parameter from `tray` to `tray_index` across the AutomatedRetrieval capability, its backends (TundraStore, Liconic, Cytomat, Heraeus), and the TundraStore frontend, including docstrings and user-facing error messages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../automated_retrieval.py | 12 ++++----- .../automated_retrieval/backend.py | 18 +++++++------ .../automated_retrieval/chatterbox.py | 8 +++--- pylabrobot/highres/tundrastore/backend.py | 22 ++++++++-------- pylabrobot/highres/tundrastore/chatterbox.py | 10 ++++--- pylabrobot/highres/tundrastore/tundrastore.py | 26 +++++++++---------- pylabrobot/liconic/backend.py | 8 +++--- pylabrobot/thermo_fisher/cytomat/backend.py | 8 +++--- .../thermo_fisher/cytomat/heraeus_backend.py | 8 +++--- 9 files changed, 62 insertions(+), 58 deletions(-) diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py index 631a96bb6c6..a35ff7e0504 100644 --- a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -17,27 +17,27 @@ def __init__(self, backend: AutomatedRetrievalBackend): self.backend: AutomatedRetrievalBackend = backend @need_capability_ready - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): """Retrieve a plate from storage and place it on a loading tray. Args: plate: The plate to retrieve. - tray: 0-based index of the loading tray to deliver to. ``None`` selects the + tray_index: 0-based index of the loading tray to deliver to. ``None`` selects the device's default tray (single-tray devices only accept ``None``/``0``). """ - await self.backend.fetch_plate_to_loading_tray(plate, tray=tray) + await self.backend.fetch_plate_to_loading_tray(plate, tray_index=tray_index) @need_capability_ready - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): """Store a plate from a loading tray into the given site. Args: plate: The plate to store. site: The destination storage site. - tray: 0-based index of the loading tray the plate is on. ``None`` selects + tray_index: 0-based index of the loading tray the plate is on. ``None`` selects the device's default tray. """ - await self.backend.store_plate(plate, site, tray=tray) + await self.backend.store_plate(plate, site, tray_index=tray_index) async def _on_stop(self): await super()._on_stop() diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py index 5aae5d93a6c..abc6527b758 100644 --- a/pylabrobot/capabilities/automated_retrieval/backend.py +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -5,38 +5,40 @@ from pylabrobot.resources import Plate, PlateHolder -def ensure_single_tray(tray: Optional[int]) -> None: +def ensure_single_tray(tray_index: Optional[int]) -> None: """Guard for backends with exactly one loading tray. - Raises ``ValueError`` if ``tray`` is anything other than ``None`` (default) or + Raises ``ValueError`` if ``tray_index`` is anything other than ``None`` (default) or ``0`` (the only tray). """ - if tray not in (None, 0): - raise ValueError(f"This device has a single loading tray; got tray={tray}. Use None or 0.") + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for automated plate retrieval/storage devices.""" @abstractmethod - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): """Retrieve a plate from storage and place it on a loading tray. Args: plate: The plate to retrieve. - tray: 0-based index of the loading tray to deliver the plate to. ``None`` + tray_index: 0-based index of the loading tray to deliver the plate to. ``None`` selects the device's default tray. Devices with a single loading tray accept ``None``/``0`` and reject any other value (see :func:`ensure_single_tray`). """ @abstractmethod - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): """Store a plate from a loading tray into the given site. Args: plate: The plate to store. site: The destination storage site. - tray: 0-based index of the loading tray the plate is currently on. ``None`` + tray_index: 0-based index of the loading tray the plate is currently on. ``None`` selects the device's default tray. """ diff --git a/pylabrobot/capabilities/automated_retrieval/chatterbox.py b/pylabrobot/capabilities/automated_retrieval/chatterbox.py index 80320e25c81..bb1959f6585 100644 --- a/pylabrobot/capabilities/automated_retrieval/chatterbox.py +++ b/pylabrobot/capabilities/automated_retrieval/chatterbox.py @@ -12,8 +12,8 @@ class AutomatedRetrievalChatterboxBackend(AutomatedRetrievalBackend): """Chatterbox backend for device-free testing.""" - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): - logger.info("Fetching plate %s to loading tray %s.", plate.name, tray) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): + logger.info("Fetching plate %s to loading tray %s.", plate.name, tray_index) - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): - logger.info("Storing plate %s at site %s (tray %s).", plate.name, site.name, tray) + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): + logger.info("Storing plate %s at site %s (tray %s).", plate.name, site.name, tray_index) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 3b22383fec3..9c5b5993d84 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -61,8 +61,8 @@ class TundraStoreBackend( three indices directly. The TundraStore has two nests. They are exposed through the multi-tray - :class:`AutomatedRetrieval` capability: its ``tray`` argument is a 0-based nest - index (tray ``i`` -> device nest ``i + 1``), and ``tray=None`` selects + :class:`AutomatedRetrieval` capability: its ``tray_index`` argument is a 0-based nest + index (tray ``i`` -> device nest ``i + 1``), and ``tray_index=None`` selects :attr:`loading_tray_nest`. :meth:`pick` / :meth:`place` address a nest by its 1-based device number directly. """ @@ -462,26 +462,26 @@ def _locate(self, site: PlateHolder) -> Tuple[int, int]: raise ValueError(f"Site '{site.name}' is not a known stacker slot; call set_racks() first.") return self._site_locations[site.name] - def _nest_for_tray(self, tray: Optional[int]) -> int: + def _nest_for_tray(self, tray_index: Optional[int]) -> int: """Map a 0-based capability tray index to a 1-based device nest number. ``None`` selects :attr:`loading_tray_nest` (the configured default nest).""" - if tray is None: + if tray_index is None: return self.loading_tray_nest - if not 0 <= tray < self.num_nests: - raise ValueError(f"TundraStore has trays 0..{self.num_nests - 1}; got tray={tray}.") - return tray + 1 + if not 0 <= tray_index < self.num_nests: + raise ValueError(f"TundraStore has trays 0..{self.num_nests - 1}; got tray_index={tray_index}.") + return tray_index + 1 - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): site = plate.parent if not isinstance(site, PlateHolder): raise ValueError(f"Plate '{plate.name}' is not in a stacker slot.") stacker, slot = self._locate(site) - await self.pick(stacker, slot, self._nest_for_tray(tray)) + await self.pick(stacker, slot, self._nest_for_tray(tray_index)) - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): stacker, slot = self._locate(site) - await self.place(stacker, slot, self._nest_for_tray(tray)) + await self.place(stacker, slot, self._nest_for_tray(tray_index)) # --- TemperatureController capability ------------------------------------- diff --git a/pylabrobot/highres/tundrastore/chatterbox.py b/pylabrobot/highres/tundrastore/chatterbox.py index 2a362acaa3c..24c9af2828a 100644 --- a/pylabrobot/highres/tundrastore/chatterbox.py +++ b/pylabrobot/highres/tundrastore/chatterbox.py @@ -40,11 +40,13 @@ async def pick(self, stacker: int, slot: int, nest: int): async def place(self, stacker: int, slot: int, nest: int): logger.info("[tundrastore] place stacker=%d slot=%d nest=%d", stacker, slot, nest) - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): - logger.info("[tundrastore] fetch plate %s to loading tray %s", plate.name, tray) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): + logger.info("[tundrastore] fetch plate %s to loading tray %s", plate.name, tray_index) - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): - logger.info("[tundrastore] store plate %s at site %s (tray %s)", plate.name, site.name, tray) + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): + logger.info( + "[tundrastore] store plate %s at site %s (tray %s)", plate.name, site.name, tray_index + ) @property def supports_active_cooling(self) -> bool: diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/tundrastore/tundrastore.py index 9c5c24001a4..bfc19e6edc2 100644 --- a/pylabrobot/highres/tundrastore/tundrastore.py +++ b/pylabrobot/highres/tundrastore/tundrastore.py @@ -29,8 +29,8 @@ class TundraStore(Resource, Device): Each rack is a *stacker* (a vertical column of plate slots); plates enter and leave through one of the device's *nests* (transfer stations). The store has two nests, modeled here as two loading trays — :attr:`nests` ``[0]`` and - ``[1]`` — addressed by the ``tray`` argument of the :class:`AutomatedRetrieval` - capability (0-based). ``tray=None`` uses :attr:`default_tray`. + ``[1]`` — addressed by the ``tray_index`` argument of the :class:`AutomatedRetrieval` + capability (0-based). ``tray_index=None`` uses :attr:`default_tray`. """ def __init__( @@ -52,7 +52,7 @@ def __init__( racks: Storage racks; rack *i* maps to device stacker ``i + 1``. nest_locations: One :class:`Coordinate` per transfer nest (the device has two). ``nest_locations[i]`` is the location of nest/tray ``i``. - default_tray: 0-based nest used when a ``tray`` argument is omitted. + default_tray: 0-based nest used when a ``tray_index`` argument is omitted. """ Resource.__init__( self, @@ -89,10 +89,10 @@ def __init__( def racks(self) -> List[PlateCarrier]: return self._racks - def _tray_index(self, tray: Optional[int]) -> int: - idx = self.default_tray if tray is None else tray + def _tray_index(self, tray_index: Optional[int]) -> int: + idx = self.default_tray if tray_index is None else tray_index if not 0 <= idx < len(self.nests): - raise ValueError(f"'{self.name}' has trays 0..{len(self.nests) - 1}; got tray={idx}.") + raise ValueError(f"'{self.name}' has trays 0..{len(self.nests) - 1}; got tray_index={idx}.") return idx async def setup(self, backend_params: Optional[BackendParams] = None): @@ -123,12 +123,12 @@ def height(p: Plate) -> float: raise NoFreeSiteError(f"No free site in '{self.name}' for plate '{plate.name}'") return sorted(available, key=lambda s: s.get_size_z()) - async def fetch_plate_to_nest(self, plate_name: str, tray: Optional[int] = None) -> Plate: + async def fetch_plate_to_nest(self, plate_name: str, tray_index: Optional[int] = None) -> Plate: """Retrieve a stored plate and place it on a nest (default :attr:`default_tray`).""" - idx = self._tray_index(tray) + idx = self._tray_index(tray_index) site = self.get_site_by_plate_name(plate_name) plate = cast(Plate, site.resource) - await self.retrieval.fetch_plate_to_loading_tray(plate, tray=idx) + await self.retrieval.fetch_plate_to_loading_tray(plate, tray_index=idx) plate.unassign() self.nests[idx].assign_child_resource(plate) return plate @@ -136,15 +136,15 @@ async def fetch_plate_to_nest(self, plate_name: str, tray: Optional[int] = None) async def take_in_plate( self, site: Union[PlateHolder, Literal["random", "smallest"]], - tray: Optional[int] = None, + tray_index: Optional[int] = None, ): """Store the plate currently on a nest into a stacker slot. Args: site: Destination slot, or ``"smallest"`` / ``"random"`` to auto-select. - tray: Which nest the plate is on (default :attr:`default_tray`). + tray_index: Which nest the plate is on (default :attr:`default_tray`). """ - idx = self._tray_index(tray) + idx = self._tray_index(tray_index) plate = cast(Plate, self.nests[idx].resource) if plate is None: raise ResourceNotFoundError(f"No plate on nest {idx} of '{self.name}'") @@ -161,7 +161,7 @@ async def take_in_plate( else: raise ValueError(f"Invalid site: {site}") - await self.retrieval.store_plate(plate, target, tray=idx) + await self.retrieval.store_plate(plate, target, tray_index=idx) plate.unassign() target.assign_child_resource(plate) diff --git a/pylabrobot/liconic/backend.py b/pylabrobot/liconic/backend.py index 53f8f3efb6b..d7c6b211fbf 100644 --- a/pylabrobot/liconic/backend.py +++ b/pylabrobot/liconic/backend.py @@ -152,8 +152,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): - ensure_single_tray(tray) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -176,8 +176,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = n, ) - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): - ensure_single_tray(tray) + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) diff --git a/pylabrobot/thermo_fisher/cytomat/backend.py b/pylabrobot/thermo_fisher/cytomat/backend.py index cda7c74238d..f31d7c16ead 100644 --- a/pylabrobot/thermo_fisher/cytomat/backend.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -125,8 +125,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): - ensure_single_tray(tray) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) logger.info( "[Cytomat %s %s] fetch plate to loading tray: plate='%s'", self.model.value, @@ -137,8 +137,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = assert isinstance(site, PlateHolder) await self.action_storage_to_transfer(site) - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): - ensure_single_tray(tray) + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) logger.info( "[Cytomat %s %s] store plate: plate='%s', site='%s'", self.model.value, diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index e47ba2cf705..d587b6285aa 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -124,8 +124,8 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = None): - ensure_single_tray(tray) + async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) logger.info("[Heraeus %s] fetch plate to loading tray: plate='%s'", self.io.port, plate.name) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -136,8 +136,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray: Optional[int] = await self._wait_ready() await self._send_command("ST 1903") - async def store_plate(self, plate: Plate, site: PlateHolder, tray: Optional[int] = None): - ensure_single_tray(tray) + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): + ensure_single_tray(tray_index) logger.info( "[Heraeus %s] store plate: plate='%s', site='%s'", self.io.port, plate.name, site.name ) From ae356cc478079470432628232bb089f2af208701 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 22:08:37 -0700 Subject: [PATCH 08/23] Remove tundrastore constants.py, relocate constants to their users Moves DoorState/NestState into standard.py (next to the DoorStatus/ NestStatus dataclasses that use them) and the protocol reply tokens (ACK!/OK!/ABORTED!/ERROR!) into backend.py, their only consumer. Drops the unused PLATE_AVAILABLE constant and deletes the now-empty module. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/__init__.py | 3 +- pylabrobot/highres/tundrastore/backend.py | 21 +++++++----- .../highres/tundrastore/backend_tests.py | 2 +- pylabrobot/highres/tundrastore/constants.py | 33 ------------------- pylabrobot/highres/tundrastore/standard.py | 19 ++++++++++- 5 files changed, 33 insertions(+), 45 deletions(-) delete mode 100644 pylabrobot/highres/tundrastore/constants.py diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index 8768f7f971a..2931a208ad3 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -1,6 +1,5 @@ from .backend import TundraStoreBackend from .chatterbox import TundraStoreChatterboxBackend -from .constants import DoorState, NestState from .errors import ( PlateNotFoundError, TundraStoreAbortedError, @@ -9,8 +8,10 @@ ) from .settings import TundraStoreSettings from .standard import ( + DoorState, DoorStatus, EnvironmentParameter, + NestState, NestStatus, StackerDimensions, VersionInfo, diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 9c5b5993d84..127c89bd855 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -11,15 +11,6 @@ from pylabrobot.io.socket import Socket from pylabrobot.resources import Plate, PlateHolder -from .constants import ( - ACK_TOKEN, - COMPLETION_ABORTED, - COMPLETION_ERROR, - COMPLETION_OK, - COMPLETION_TOKENS, - DoorState, - NestState, -) from .errors import ( PlateNotFoundError, TundraStoreAbortedError, @@ -29,8 +20,10 @@ ) from .settings import TundraStoreSettings from .standard import ( + DoorState, DoorStatus, EnvironmentParameter, + NestState, NestStatus, StackerDimensions, VersionInfo, @@ -38,6 +31,16 @@ logger = logging.getLogger(__name__) +# Completion-status tokens that terminate a command's reply (see the manual, +# "Message Formatting"). Every command ends with exactly one of these. +COMPLETION_OK = "OK!" +COMPLETION_ABORTED = "ABORTED!" +COMPLETION_ERROR = "ERROR!" +COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) + +# Immediate command-receipt echo prefix. +ACK_TOKEN = "ACK!" + class TundraStoreBackend( AutomatedRetrievalBackend, diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index fc7f0f6ad24..da76f5e5503 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -2,12 +2,12 @@ from typing import Dict, List from pylabrobot.highres.tundrastore.backend import TundraStoreBackend -from pylabrobot.highres.tundrastore.constants import DoorState, NestState from pylabrobot.highres.tundrastore.errors import ( PlateNotFoundError, TundraStoreError, TundraStoreFault, ) +from pylabrobot.highres.tundrastore.standard import DoorState, NestState # Real responses captured from a TundraStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. diff --git a/pylabrobot/highres/tundrastore/constants.py b/pylabrobot/highres/tundrastore/constants.py deleted file mode 100644 index 46caf5b1cf1..00000000000 --- a/pylabrobot/highres/tundrastore/constants.py +++ /dev/null @@ -1,33 +0,0 @@ -import enum - - -class DoorState(enum.Enum): - """State of a single TundraStore door, as reported by ``doorstatus``.""" - - OPEN = "OPEN" - CLOSED = "CLOSED" - OPENING = "OPENING" - CLOSING = "CLOSING" - UNKNOWN = "UNKNOWN" - - -class NestState(enum.Enum): - """State of a transfer nest, as reported by ``neststatus``.""" - - CLEAR = "CLEAR" - OCCUPIED = "OCCUPIED" - UNKNOWN = "UNKNOWN" - - -# Completion-status tokens that terminate a command's reply (see the manual, -# "Message Formatting"). Every command ends with exactly one of these. -COMPLETION_OK = "OK!" -COMPLETION_ABORTED = "ABORTED!" -COMPLETION_ERROR = "ERROR!" -COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) - -# Immediate command-receipt echo prefix. -ACK_TOKEN = "ACK!" - -# Intermediate data line emitted by ``pick`` once the plate is clear of the nest. -PLATE_AVAILABLE = "PLATE_AVAILABLE" diff --git a/pylabrobot/highres/tundrastore/standard.py b/pylabrobot/highres/tundrastore/standard.py index 2678eb4c980..58a9d64441e 100644 --- a/pylabrobot/highres/tundrastore/standard.py +++ b/pylabrobot/highres/tundrastore/standard.py @@ -1,7 +1,24 @@ +import enum from dataclasses import dataclass from typing import Dict, Optional -from .constants import DoorState, NestState + +class DoorState(enum.Enum): + """State of a single TundraStore door, as reported by ``doorstatus``.""" + + OPEN = "OPEN" + CLOSED = "CLOSED" + OPENING = "OPENING" + CLOSING = "CLOSING" + UNKNOWN = "UNKNOWN" + + +class NestState(enum.Enum): + """State of a transfer nest, as reported by ``neststatus``.""" + + CLEAR = "CLEAR" + OCCUPIED = "OCCUPIED" + UNKNOWN = "UNKNOWN" @dataclass From 491edf8792b562d4c9b40679bf11414f5ce88af2 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 22:12:55 -0700 Subject: [PATCH 09/23] Inline single-tray guard, drop ensure_single_tray helper Removes the ensure_single_tray free function (and its export from the automated_retrieval capability) and inlines the equivalent ``tray_index not in (None, 0)`` check directly into the fetch/store methods of the single-tray Liconic, Cytomat, and Heraeus backends. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../capabilities/automated_retrieval/__init__.py | 2 +- .../capabilities/automated_retrieval/backend.py | 15 +-------------- pylabrobot/liconic/backend.py | 15 +++++++++------ pylabrobot/thermo_fisher/cytomat/backend.py | 15 +++++++++------ .../thermo_fisher/cytomat/heraeus_backend.py | 15 +++++++++------ 5 files changed, 29 insertions(+), 33 deletions(-) diff --git a/pylabrobot/capabilities/automated_retrieval/__init__.py b/pylabrobot/capabilities/automated_retrieval/__init__.py index 5ddc8c2a5db..b4bd3544909 100644 --- a/pylabrobot/capabilities/automated_retrieval/__init__.py +++ b/pylabrobot/capabilities/automated_retrieval/__init__.py @@ -1,2 +1,2 @@ from .automated_retrieval import AutomatedRetrieval -from .backend import AutomatedRetrievalBackend, ensure_single_tray +from .backend import AutomatedRetrievalBackend diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py index abc6527b758..bd477ecf8f0 100644 --- a/pylabrobot/capabilities/automated_retrieval/backend.py +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -5,18 +5,6 @@ from pylabrobot.resources import Plate, PlateHolder -def ensure_single_tray(tray_index: Optional[int]) -> None: - """Guard for backends with exactly one loading tray. - - Raises ``ValueError`` if ``tray_index`` is anything other than ``None`` (default) or - ``0`` (the only tray). - """ - if tray_index not in (None, 0): - raise ValueError( - f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." - ) - - class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for automated plate retrieval/storage devices.""" @@ -28,8 +16,7 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[i plate: The plate to retrieve. tray_index: 0-based index of the loading tray to deliver the plate to. ``None`` selects the device's default tray. Devices with a single loading tray - accept ``None``/``0`` and reject any other value (see - :func:`ensure_single_tray`). + accept ``None``/``0`` and reject any other value. """ @abstractmethod diff --git a/pylabrobot/liconic/backend.py b/pylabrobot/liconic/backend.py index d7c6b211fbf..66569be0880 100644 --- a/pylabrobot/liconic/backend.py +++ b/pylabrobot/liconic/backend.py @@ -13,10 +13,7 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import ( - AutomatedRetrievalBackend, - ensure_single_tray, -) +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend @@ -153,7 +150,10 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -177,7 +177,10 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[i ) async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) diff --git a/pylabrobot/thermo_fisher/cytomat/backend.py b/pylabrobot/thermo_fisher/cytomat/backend.py index f31d7c16ead..5284ab19a98 100644 --- a/pylabrobot/thermo_fisher/cytomat/backend.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -12,10 +12,7 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import ( - AutomatedRetrievalBackend, - ensure_single_tray, -) +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend @@ -126,7 +123,10 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) logger.info( "[Cytomat %s %s] fetch plate to loading tray: plate='%s'", self.model.value, @@ -138,7 +138,10 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[i await self.action_storage_to_transfer(site) async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) logger.info( "[Cytomat %s %s] store plate: plate='%s', site='%s'", self.model.value, diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index d587b6285aa..4fd00785563 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -12,10 +12,7 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.automated_retrieval.backend import ( - AutomatedRetrievalBackend, - ensure_single_tray, -) +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend @@ -125,7 +122,10 @@ async def set_racks(self, racks: List[PlateCarrier]): # -- AutomatedRetrievalBackend -- async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) logger.info("[Heraeus %s] fetch plate to loading tray: plate='%s'", self.io.port, plate.name) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -137,7 +137,10 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[i await self._send_command("ST 1903") async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): - ensure_single_tray(tray_index) + if tray_index not in (None, 0): + raise ValueError( + f"This device has a single loading tray; got tray_index={tray_index}. Use None or 0." + ) logger.info( "[Heraeus %s] store plate: plate='%s', site='%s'", self.io.port, plate.name, site.name ) From bdb2b4f13bf955472c173dc30acebd74aef8eea5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 22:15:04 -0700 Subject: [PATCH 10/23] Drop DoorStatus/NestStatus wrappers, return dicts directly These single-field dataclasses just wrapped a dict. request_door_status now returns Dict[str, DoorState] and request_nest_status returns Dict[int, NestState] directly. The only behavior on them, DoorStatus. all_closed, was test-only and is inlined at its sole call site. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/__init__.py | 2 -- pylabrobot/highres/tundrastore/backend.py | 14 +++++++------- .../highres/tundrastore/backend_tests.py | 14 +++++++------- pylabrobot/highres/tundrastore/standard.py | 18 ------------------ 4 files changed, 14 insertions(+), 34 deletions(-) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index 2931a208ad3..b3740aa8c8e 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -9,10 +9,8 @@ from .settings import TundraStoreSettings from .standard import ( DoorState, - DoorStatus, EnvironmentParameter, NestState, - NestStatus, StackerDimensions, VersionInfo, ) diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 127c89bd855..aad3654bf9c 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -21,10 +21,8 @@ from .settings import TundraStoreSettings from .standard import ( DoorState, - DoorStatus, EnvironmentParameter, NestState, - NestStatus, StackerDimensions, VersionInfo, ) @@ -225,16 +223,18 @@ async def is_homed(self) -> bool: lines = await self.send_command("homedstatus") return any(line.strip().lower() == "homed" for line in lines) - async def request_door_status(self) -> DoorStatus: + async def request_door_status(self) -> Dict[str, DoorState]: + """Parsed ``doorstatus`` output, keyed by door name.""" doors: Dict[str, DoorState] = {} for name, value in self._parse_kv(await self.send_command("doorstatus")).items(): try: doors[name] = DoorState(value) except ValueError: doors[name] = DoorState.UNKNOWN - return DoorStatus(doors=doors) + return doors - async def request_nest_status(self) -> NestStatus: + async def request_nest_status(self) -> Dict[int, NestState]: + """Parsed ``neststatus`` output, keyed by nest number.""" nests: Dict[int, NestState] = {} for key, value in self._parse_kv(await self.send_command("neststatus")).items(): try: @@ -245,7 +245,7 @@ async def request_nest_status(self) -> NestStatus: nests[nest] = NestState(value) except ValueError: nests[nest] = NestState.UNKNOWN - return NestStatus(nests=nests) + return nests async def spatula_request_is_holding(self) -> bool: """Whether a plate is currently held on the spatula (``platestatus``).""" @@ -258,7 +258,7 @@ async def nest_request_is_holding(self, nest: int) -> bool: Note: this unit reports an occupied nest as ``UNKNOWN`` rather than ``OCCUPIED``; anything other than ``CLEAR`` counts as holding. """ - state = (await self.request_nest_status()).nests.get(nest) + state = (await self.request_nest_status()).get(nest) return state is not None and state is not NestState.CLEAR async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> bool: diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index da76f5e5503..697d7896b26 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -107,15 +107,15 @@ async def test_is_homed(self): self.assertFalse(await self.backend.is_homed()) async def test_door_status(self): - status = await self.backend.request_door_status() - self.assertEqual(status.doors["User Door"], DoorState.CLOSED) - self.assertEqual(status.doors["SEAL"], DoorState.CLOSING) - self.assertEqual(status.doors["RO1"], DoorState.CLOSING) - self.assertFalse(status.all_closed) + doors = await self.backend.request_door_status() + self.assertEqual(doors["User Door"], DoorState.CLOSED) + self.assertEqual(doors["SEAL"], DoorState.CLOSING) + self.assertEqual(doors["RO1"], DoorState.CLOSING) + self.assertFalse(all(state is DoorState.CLOSED for state in doors.values())) async def test_nest_status(self): - status = await self.backend.request_nest_status() - self.assertEqual(status.nests, {1: NestState.CLEAR, 2: NestState.CLEAR}) + nests = await self.backend.request_nest_status() + self.assertEqual(nests, {1: NestState.CLEAR, 2: NestState.CLEAR}) async def test_plate_on_spatula(self): self.assertFalse(await self.backend.spatula_request_is_holding()) diff --git a/pylabrobot/highres/tundrastore/standard.py b/pylabrobot/highres/tundrastore/standard.py index 58a9d64441e..c13faa6ef66 100644 --- a/pylabrobot/highres/tundrastore/standard.py +++ b/pylabrobot/highres/tundrastore/standard.py @@ -46,24 +46,6 @@ class EnvironmentParameter: limit: Optional[float] = None -@dataclass -class DoorStatus: - """Parsed output of the ``doorstatus`` command, keyed by door name.""" - - doors: Dict[str, DoorState] - - @property - def all_closed(self) -> bool: - return all(state is DoorState.CLOSED for state in self.doors.values()) - - -@dataclass -class NestStatus: - """Parsed output of the ``neststatus`` command, keyed by nest number.""" - - nests: Dict[int, NestState] - - @dataclass class StackerDimensions: """One stacker's geometry, from ``getstackerdimensions``.""" From aa9ca2bdeb137478b242c1e8ad6cbc9142c63d4b Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 23:11:05 -0700 Subject: [PATCH 11/23] Make TundraStoreSettings a frozen, fully-typed dataclass Replaces the raw-dict-plus-property settings view with a frozen dataclass exposing all 545 device settings as explicit typed attributes (device key lower-cased), with types inferred from the device's values. machine_type is a Literal of known models and warns ("please contribute") on an unknown model. Settings are loaded whole from the device; no shipped defaults. request_settings() is simplified accordingly: drops the unused search filter and the separate serial/firmware plumbing (SERIAL_NUMBER is now a field; firmware stays on request_version). Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/__init__.py | 2 +- pylabrobot/highres/tundrastore/backend.py | 14 +- pylabrobot/highres/tundrastore/settings.py | 783 ++++++++++++++++++--- 3 files changed, 691 insertions(+), 108 deletions(-) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index b3740aa8c8e..39676d9754b 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -6,7 +6,7 @@ TundraStoreError, TundraStoreFault, ) -from .settings import TundraStoreSettings +from .settings import MachineType, TundraStoreSettings from .standard import ( DoorState, EnvironmentParameter, diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index aad3654bf9c..ed946c8bf3d 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -326,15 +326,11 @@ async def get_stacker_dimensions(self) -> List[StackerDimensions]: continue return dims - async def request_settings(self, search: str = "") -> TundraStoreSettings: - """Read the device's settings file (``NAME = value`` pairs) into a - :class:`TundraStoreSettings`. Pass ``search`` to filter by substring.""" - command = "settings" + (f" {search}" if search else "") - lines = await self.send_command(command, timeout=self._read_timeout) - version = await self.request_version() - return TundraStoreSettings.from_lines( - lines, serial=version.serial_number, firmware=version.firmware_version - ) + async def request_settings(self) -> TundraStoreSettings: + """Read the device's full settings file (``NAME = value`` pairs) into a + frozen :class:`TundraStoreSettings`.""" + lines = await self.send_command("settings", timeout=self._read_timeout) + return TundraStoreSettings.from_lines(lines) async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: """Scan a stacker (or a single slot) for barcodes. diff --git a/pylabrobot/highres/tundrastore/settings.py b/pylabrobot/highres/tundrastore/settings.py index e4d714a1492..315c972a460 100644 --- a/pylabrobot/highres/tundrastore/settings.py +++ b/pylabrobot/highres/tundrastore/settings.py @@ -1,136 +1,723 @@ +"""Typed, immutable view of a TundraStore's on-device settings. + +The device exposes its full calibration/configuration via the ``settings`` +command as ``NAME = value`` text. Each key is surfaced here as one explicitly +typed attribute (the device ``NAME`` lower-cased); types are inferred from the +device's own values. A :class:`TundraStoreSettings` is loaded whole from the +device (or a capture) and is frozen once built. +""" + import json -from dataclasses import dataclass, field -from typing import Dict, Optional +import warnings +from dataclasses import dataclass, fields +from typing import Any, Iterable, Mapping, Tuple + +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal # type: ignore + + +# Known TundraStore/SteriStore models, as reported by the device's MACHINE_TYPE +# setting. Extend this (and MachineType) when a new model is encountered. +MachineType = Literal["SteriStore2"] +KNOWN_MACHINE_TYPES: Tuple[str, ...] = ("SteriStore2",) + +def _coerce(typ: Any, raw: Any) -> Any: + if typ is int: + return int(raw) + if typ is float: + return float(raw) + return str(raw) -@dataclass + +@dataclass(frozen=True) class TundraStoreSettings: - """Client-side view of the TundraStore's on-device settings file. + """All on-device settings, one typed attribute per device key.""" + + product_name: str + product_description: str + + serial_number: str + + machine_type: MachineType + + rest_server_port: int + + syslog_server: str + syslog_level: int + + internal_log_level: int + + carousel_home_speed_fast: float + carousel_home_speed_slow: float + carousel_home_acceleration: float + carousel_velocity: float + carousel_idle_velocity: float + carousel_acceleration: float + carousel_abort_deceleration: float + carousel_jerk: float + carousel_final_drive_jerk: float + carousel_stacker_0_pos: float + carousel_stacker_1_pos: float + carousel_stacker_2_pos: float + carousel_stacker_count: int + carousel_count: int + carousel_calibration_offset: float + + spatula_home_speed_fast: float + spatula_home_speed_slow: float + spatula_home_acceleration: float + spatula_velocity: float + spatula_velocity_with_plate: float + spatula_acceleration: float + spatula_abort_deceleration: float + spatula_jerk: float + spatula_rot_home_speed_fast: float + spatula_rot_home_speed_slow: float + spatula_rot_home_acceleration: float + spatula_rot_velocity: float + spatula_rot_acceleration: float + spatula_rot_abort_deceleration: float + spatula_rot_jerk: float + spatula_rot_zero_pos: float + spatula_rot_stack_pos_0: float + spatula_rot_stack_pos_1: float + spatula_rot_stack_pos_2: float + spatula_rot_nest_1_pos: float + spatula_rot_nest_2_pos: float + spatula_rot_nest_3_pos: float + spatula_rot_nest_4_pos: float + spatula_rot_nest_5_pos: float + spatula_rot_nest_6_pos: float + spatula_rot_nest_7_pos: float + spatula_rot_nest_8_pos: float + spatula_rot_nest_9_pos: float + spatula_rot_nest_10_pos: float + spatula_rot_nest_21_pos: float + spatula_rot_nest_22_pos: float + spatula_rot_nest_23_pos: float + spatula_rot_nest_24_pos: float + spatula_rot_nest_51_pos: float + spatula_rot_nest_52_pos: float + spatula_rot_nest_61_pos: float + spatula_rot_nest_62_pos: float + spatula_rot_nest_63_pos: float + spatula_rot_nest_64_pos: float + spatula_rot_nest_65_pos: float + spatula_rot_nest_66_pos: float + spatula_rot_nest_67_pos: float + spatula_rot_nest_68_pos: float + spatula_rot_nest_69_pos: float + spatula_slide_home_speed_fast: float + spatula_slide_home_speed_slow: float + spatula_slide_home_acceleration: float + spatula_slide_home_offset: float + spatula_slide_velocity: float + spatula_slide_acceleration: float + spatula_slide_abort_deceleration: float + spatula_slide_jerk: float + spatula_slide_in_pos_0: float + spatula_slide_in_pos_1: float + spatula_slide_in_pos_2: float + spatula_slide_nest_1_pos: float + spatula_slide_nest_2_pos: float + spatula_slide_nest_3_pos: float + spatula_slide_nest_4_pos: float + spatula_slide_nest_5_pos: float + spatula_slide_nest_6_pos: float + spatula_slide_nest_7_pos: float + spatula_slide_nest_8_pos: float + spatula_slide_nest_9_pos: float + spatula_slide_nest_10_pos: float + spatula_slide_nest_21_pos: float + spatula_slide_nest_22_pos: float + spatula_slide_nest_23_pos: float + spatula_slide_nest_24_pos: float + spatula_slide_nest_51_pos: float + spatula_slide_nest_52_pos: float + spatula_slide_nest_61_pos: float + spatula_slide_nest_62_pos: float + spatula_slide_nest_63_pos: float + spatula_slide_nest_64_pos: float + spatula_slide_nest_65_pos: float + spatula_slide_nest_66_pos: float + spatula_slide_nest_67_pos: float + spatula_slide_nest_68_pos: float + spatula_slide_nest_69_pos: float + spatula_valve_hold: int + spatula_plate_sensor: int + spatula_plate_release_sensor: int + + inner_user_door_sensor: int + + door_open_sensor_output: int + + nest_count: int + nest_1_height: float + nest_2_height: float + nest_3_height: float + nest_4_height: float + nest_5_height: float + nest_6_height: float + nest_7_height: float + nest_8_height: float + nest_9_height: float + nest_10_height: float + nest_21_height: float + nest_22_height: float + nest_23_height: float + nest_24_height: float + nest_51_height: float + nest_52_height: float + nest_61_height: float + nest_62_height: float + nest_63_height: float + nest_64_height: float + nest_65_height: float + nest_66_height: float + nest_67_height: float + nest_68_height: float + nest_69_height: float + nest_1_style: str + nest_2_style: str + nest_3_style: str + nest_4_style: str + nest_5_style: str + nest_6_style: str + nest_7_style: str + nest_8_style: str + nest_9_style: str + nest_10_style: str + nest_clearance_above: float + nest_clearance_below: float + + handover_nest_clearance_above: float + handover_nest_clearance_below: float + handover_y_rotation_position: float + + conveyor_clearance_above: float + conveyor_clearance_below: float + + static_nest_clearance_above: float + static_nest_clearance_below: float + + io_nest_clearance_above: float + io_nest_clearance_below: float + + nest_1_sense_input: int + nest_2_sense_input: int + nest_3_sense_input: int + nest_4_sense_input: int + nest_5_sense_input: int + nest_6_sense_input: int + nest_7_sense_input: int + nest_8_sense_input: int + nest_9_sense_input: int + nest_10_sense_input: int + + stacker_base_0: float + stacker_base_1: float + stacker_base_2: float + + barcode_base_0: float + barcode_base_1: float + + stacker_clearance_above: float + stacker_clearance_below: float + + barcode_scanner: str + barcode_laser_start: int + barcode_laser_stop: int + barcode_velocity: float + barcode_acceleration: float + barcode_config_itf_enable: str + barcode_config_itf_status: str + barcode_config_itf_length_1: int + barcode_config_itf_length_2: int + barcode_config_itf_range: str + + plate_hold_settle_time: int + + door_0_position: float + door_height: float + door_overlap_negative: float + door_overlap_positive: float + door_gasket_valve: int + + big_door_valve: int + + door_1_valve: int + door_2_valve: int + door_3_valve: int + door_4_valve: int + door_5_valve: int + door_6_valve: int + door_7_valve: int + door_8_valve: int + door_1_open_sensor: int + door_2_open_sensor: int + door_3_open_sensor: int + door_4_open_sensor: int + door_5_open_sensor: int + door_6_open_sensor: int + door_7_open_sensor: int + door_8_open_sensor: int + door_1_close_sensor: int + door_2_close_sensor: int + door_3_close_sensor: int + door_4_close_sensor: int + door_5_close_sensor: int + door_6_close_sensor: int + door_7_close_sensor: int + door_8_close_sensor: int + door_ri_open_sensor: int + door_ri_close_sensor: int + + gasket_deflate_delay_ms: int + gasket_inflate_delay_ms: int + + big_door_open_delay_ms: int + big_door_close_delay_ms: int + + door_open_delay_ms: int + door_close_delay_ms: int + door_open_signal_active_level: int + + vac_1_enable: int + vac_2_enable: int + vac_1_purge: int + vac_2_purge: int + + lift_1_enable: int + lift_2_enable: int + + nest_1_sense: int + nest_2_sense: int + + vac_3_enable: int + vac_4_enable: int + vac_3_purge: int + vac_4_purge: int + + lift_3_enable: int + lift_4_enable: int + + nest_3_sense: int + nest_4_sense: int + + vac_5_enable: int + vac_6_enable: int + vac_5_purge: int + vac_6_purge: int + + lift_5_enable: int + lift_6_enable: int + + nest_5_sense: int + nest_6_sense: int - The device exposes ~545 calibration/config keys via the ``settings`` command - as ``NAME = value`` text. Values are kept verbatim as strings in :attr:`raw` - (the device's own representation); use the typed accessors and named - properties below for parsed values. Addresses like ``2004007`` are - Copley/digital-IO register addresses a sensor is wired to. - """ + vac_7_enable: int + vac_8_enable: int + vac_7_purge: int + vac_8_purge: int - raw: Dict[str, str] = field(default_factory=dict) - serial_number: Optional[str] = None - firmware_version: Optional[str] = None + lift_7_enable: int + lift_8_enable: int - # --- generic typed access ------------------------------------------------- + nest_7_sense: int + nest_8_sense: int - def get(self, key: str) -> Optional[str]: - return self.raw.get(key) + active_hotels: int - def get_float(self, key: str) -> Optional[float]: - v = self.raw.get(key) - try: - return float(v) if v is not None else None - except ValueError: - return None + nest_rot_home_speed_fast: int + nest_rot_home_speed_slow: int + nest_rot_home_acceleration: int + nest_rot_velocity: int + nest_rot_acceleration: int + nest_rot_abort_deceleration: int + nest_rot_jerk: int + nest_rot_zero_pos: float - def get_int(self, key: str) -> Optional[int]: - v = self.raw.get(key) - try: - return int(v) if v is not None else None - except ValueError: - return None + microspin_door_closed: float + microspin_door_open: float + microspin_spindle_home_offset: float + microspin_bucket_radius_m: float + microspin_spindle_counts_per_rev: int + microspin_spindle_position_window: int + microspin_door_velocity: int + microspin_door_home_velocity: int + microspin_door_accel: int + microspin_door_abort_decel: int + microspin_door_jerk: int + microspin_spindle_home_velocity: int + microspin_spindle_velocity: int + microspin_spindle_accel: float + microspin_spindle_decel: float + microspin_spindle_slow_accel: float + microspin_spindle_slow_decel: float + microspin_spindle_abort_decel: float + microspin_spindle_jerk: int + microspin_spindle_max_accel: float + microspin_spindle_max_decel: float + microspin_idle_spindle_threshold: float + microspin_bucket_rise_rpm: int - # --- named accessors for settings the driver reasons about ---------------- + pico_rot_home_speed_fast: int + pico_rot_home_speed_slow: int + pico_rot_home_acceleration: int + pico_rot_velocity: int + pico_rot_acceleration: int + pico_rot_abort_deceleration: int + pico_rot_jerk: int + pico_rot_zero_pos: int + pico_stacker_count: int - @property - def height_detect_base(self) -> Optional[float]: - return self.get_float("HEIGHT_DETECT_BASE") + def_plate_height: float + def_stack_height: float + def_plate_thickness: float - @property - def height_detect_positive_adjustment(self) -> Optional[float]: - return self.get_float("HEIGHT_DETECT_POSITIVE_ADJUSTMENT") + jiggle_count: int + jiggle_size: int - @property - def height_detect_negative_adjustment(self) -> Optional[float]: - return self.get_float("HEIGHT_DETECT_NEGATIVE_ADJUSTMENT") + has_lock_sensor: str - @property - def height_detect_input_number(self) -> Optional[int]: - return self.get_int("HEIGHT_DETECT_INPUT_NUMBER") + lock_sensor_input: int - @property - def height_detect_enable_address(self) -> Optional[int]: - return self.get_int("HEIGHT_DETECT_ENABLE_ADDRESS") + carousel_max_position: int + carousel_max_velocity: int + carousel_max_acceleration: int + carousel_max_deceleration: int + carousel_max_jerk: int + carousel_home_pos_offset: int + carousel_home_neg_offset: float + carousel_homing_speed: int + carousel_home_fast: int + carousel_home_slow: int + carousel_home_accel: int + carousel_stacker_width_default: float + carousel_small_flag_width: float + carousel_large_flag_check_distance: float + carousel_large_flag_width: float + carousel_ring_numbering: str - @property - def spatula_beam_break_height(self) -> Optional[float]: - return self.get_float("SPATULA_BEAM_BREAK_HEIGHT") + effectuator_extended_position: float + effectuator_max_position: float + effectuator_lock_position: float + effectuator_unlock_position: int + effectuator_max_velocity: int + effectuator_max_acceleration: int + effectuator_abort_deceleration: int + effectuator_max_jerk: int + effectuator_home_offset: int + effectuator_home_fast: int + effectuator_home_slow: int + effectuator_home_accel: int - @property - def default_plate_height(self) -> Optional[float]: - return self.get_float("DEF_PLATE_HEIGHT") + spatula_max_position: float + spatula_max_velocity: int + spatula_measure_velocity: int + spatula_max_acceleration: int + spatula_max_jerk: int + spatula_home_offset: int + spatula_home_fast: int + spatula_home_slow: int + spatula_home_accel: int + spatula_nest_offset: float + spatula_measurement_tolerance: float + spatula_beam_break_height: float - @property - def default_plate_thickness(self) -> Optional[float]: - return self.get_float("DEF_PLATE_THICKNESS") + max_transparency_width_um: int - def stacker_base(self, bank: int) -> Optional[float]: - """Z height of stacker base ``bank`` (slot 1 sits here).""" - return self.get_float(f"STACKER_BASE_{bank}") + spatula_lock_position: float + spatula_base_position: float - def nest_height(self, nest: int) -> Optional[float]: - return self.get_float(f"NEST_{nest}_HEIGHT") + barcode_max_position: float + barcode_max_velocity: int + barcode_max_move_velocity: int + barcode_abort_deceleration: int + barcode_max_move_jerk: int + barcode_max_read_velocity: int + barcode_home_offset: int + barcode_home_fast: int + barcode_home_slow: int + barcode_home_accel: int - def nest_sense_input(self, nest: int) -> Optional[int]: - """Digital-IO address of nest ``nest``'s plate-presence sensor.""" - return self.get_int(f"NEST_{nest}_SENSE_INPUT") + spatula_slide_safe_position: float + spatula_slide_barcode_position: float - # --- (de)serialization ---------------------------------------------------- + nest_safe_rotation_clearance: float + + limit_x_min: float + limit_x_max: float + limit_y_min: float + limit_y_max: float + limit_z_min: float + limit_z_max: float + limit_theta_min: float + limit_theta_max: float + limit_g_min: float + limit_g_max: float + limit_barcode_min: float + limit_barcode_max: float + + tundra_door_cycle_active: str + tundra_door_cycle_time_sec: int + tundra_door_cycle_open_time_sec: int + + barcode_height_adjust: float + + tundra_outer_door_cycle_active: str + tundra_outer_door_cycle_time_sec: int + tundra_outer_door_cycle_open_time_sec: int + + spatula_door_clearance_below: float + spatula_door_clearance_above: float + + weigh_cell_door_output: int + weigh_cell_door_input_status_0: int + weigh_cell_door_input_status_1: int + weigh_cell_door_input_status_2: int + weigh_cell_led_red_output: int + weigh_cell_led_green_output: int + weigh_cell_led_blue_output: int + + axis_x_home_speed_fast: float + axis_x_home_speed_slow: float + axis_x_home_acceleration: float + axis_x_home_offset: float + axis_x2_home_offset: float + + tray_to_gantry_0_distance: float + + axis_x2_calibration_adjustment: float + axis_x_calibration_pos: float + axis_x_velocity: float + axis_x_acceleration: float + axis_x_abort_deceleration: float + axis_x_jerk: float + axis_z_home_speed_fast: float + axis_z_home_speed_slow: float + axis_z_home_acceleration: float + axis_z_home_offset: float + axis_z_home_offset_hardstop: float + axis_z_home_hardstop_current_ma: int + axis_z_home_hardstop_current_time_ms: int + axis_z_velocity: float + axis_z_acceleration: float + axis_z_abort_deceleration: float + axis_z_jerk: float + axis_barcode_home_speed_fast: float + axis_barcode_home_speed_slow: float + axis_barcode_home_acceleration: float + axis_barcode_home_offset: float + axis_barcode_velocity: float + axis_barcode_acceleration: float + axis_barcode_abort_deceleration: float + axis_barcode_jerk: float + axis_gripper_home_speed_fast: float + axis_gripper_home_speed_slow: float + axis_gripper_home_acceleration: float + axis_gripper_home_offset: float + axis_gripper_home_offset_hardstop: float + axis_gripper_home_hardstop_current_ma: int + axis_gripper_home_hardstop_current_time_ms: int + axis_gripper_close_position: float + axis_gripper_velocity: float + axis_gripper_acceleration: float + axis_gripper_abort_deceleration: float + axis_gripper_jerk: float + + height_detect_base: float + height_detect_positive_adjustment: float + height_detect_negative_adjustment: float + height_detect_enable_address: int + + barcode_input_number: int + + height_detect_input_number: int + + stacker_code_clearance_above: float + stacker_code_clearance_below: float + stacker_code_height: float + + barcode_fixture_height: float + barcode_fixture_groove_height: float + barcode_ideal_stacker_tier_1: float + barcode_ideal_stacker_tier_2: float + + muting_bank_input_1: int + muting_bank_input_2: int + muting_bank_input_3: int + muting_input_1: int + muting_input_2: int + + tool_head_sel_0: int + tool_head_sel_1: int + tool_head_addr_0: int + tool_head_addr_1: int + tool_head_addr_2: int + tool_head_addr_3: int + + ion_bar_air_output: int + ion_bar_power_output: int + + busybox_mode: str + + microserve_bus_voltage_threshold: float + microserve_recover_after_estop: str + + randomserve_bus_voltage_threshold: float + randomserve_recover_after_estop: str + + psp_packet_delay: int + + microspin_spindle_voltage_delay: int + microspin_bus_voltage_threshold: float + + home_trays_to_hardstop: str + + dc_out_1_default_on: str + dc_out_2_default_on: str + dc_out_3_default_on: str + + lid_discard_drop_wait_time_ms: int + + lidvalet_plate_dropped_threshold: int + lidvalet_hold_time_after_unlid: int + lidvalet_purge_time_ms: int + lidvalet_drop_down_time_ms: int + lidvalet_drop_up_time_ms: int + lidvalet_pickup_wait_ms: int + + disable_blink_function: str + + oled_blink_time_ms: int + + suppress_copley_debug_statements: str + + prime_waste_chute_installed: str + prime_waste_chute_position: str + + plate_sensor_high_is_plate_present: str + + stacker_1_speed_multiplier: float + stacker_2_speed_multiplier: float + stacker_3_speed_multiplier: float + stacker_4_speed_multiplier: float + stacker_5_speed_multiplier: float + stacker_6_speed_multiplier: float + stacker_7_speed_multiplier: float + stacker_8_speed_multiplier: float + stacker_9_speed_multiplier: float + stacker_10_speed_multiplier: float + stacker_11_speed_multiplier: float + stacker_12_speed_multiplier: float + stacker_13_speed_multiplier: float + stacker_14_speed_multiplier: float + stacker_15_speed_multiplier: float + stacker_16_speed_multiplier: float + stacker_17_speed_multiplier: float + stacker_18_speed_multiplier: float + stacker_19_speed_multiplier: float + stacker_20_speed_multiplier: float + stacker_21_speed_multiplier: float + stacker_22_speed_multiplier: float + stacker_23_speed_multiplier: float + stacker_24_speed_multiplier: float + stacker_25_speed_multiplier: float + stacker_26_speed_multiplier: float + stacker_27_speed_multiplier: float + stacker_28_speed_multiplier: float + stacker_1_clearance_above_offset: float + stacker_2_clearance_above_offset: float + stacker_3_clearance_above_offset: float + stacker_4_clearance_above_offset: float + stacker_5_clearance_above_offset: float + stacker_6_clearance_above_offset: float + stacker_7_clearance_above_offset: float + stacker_8_clearance_above_offset: float + stacker_9_clearance_above_offset: float + stacker_10_clearance_above_offset: float + stacker_11_clearance_above_offset: float + stacker_12_clearance_above_offset: float + stacker_13_clearance_above_offset: float + stacker_14_clearance_above_offset: float + stacker_15_clearance_above_offset: float + stacker_16_clearance_above_offset: float + stacker_17_clearance_above_offset: float + stacker_18_clearance_above_offset: float + stacker_19_clearance_above_offset: float + stacker_20_clearance_above_offset: float + stacker_21_clearance_above_offset: float + stacker_22_clearance_above_offset: float + stacker_23_clearance_above_offset: float + stacker_24_clearance_above_offset: float + stacker_25_clearance_above_offset: float + stacker_26_clearance_above_offset: float + stacker_27_clearance_above_offset: float + stacker_28_clearance_above_offset: float + + mfg_door_time_low_limit_ms: int + mfg_door_time_high_limit_ms: int + + automation_door_time_ms: int + + carousel_home_adj_low_limit: int + carousel_home_adj_high_limit: int + + store_calibration_fixture_y_distance: float + store_y_teach_minimum: float + store_y_teach_maximum: float + + lidvalet_wait_for_lift_rise_ms: int @classmethod - def from_dict( - cls, data: Dict, serial: Optional[str] = None, firmware: Optional[str] = None - ) -> "TundraStoreSettings": - return cls( - raw={str(k): str(v) for k, v in data.items()}, - serial_number=serial, - firmware_version=firmware, - ) + def from_dict(cls, data: Mapping[str, str]) -> "TundraStoreSettings": + """Build from a mapping of device ``NAME`` -> value (the device's own keys).""" + values = {} + missing = [] + for f in fields(cls): + key = f.name.upper() + if key not in data: + missing.append(key) + continue + values[f.name] = _coerce(f.type, data[key]) + if missing: + raise ValueError( + f"settings is missing {len(missing)} expected key(s): " + + ", ".join(missing[:8]) + + ("..." if len(missing) > 8 else "") + ) + machine_type = values["machine_type"] + if machine_type not in KNOWN_MACHINE_TYPES: + warnings.warn( + f"unknown TundraStore model {machine_type!r}; please contribute it to " + "MachineType / KNOWN_MACHINE_TYPES", + stacklevel=2, + ) + return cls(**values) @classmethod - def from_lines( - cls, lines, serial: Optional[str] = None, firmware: Optional[str] = None - ) -> "TundraStoreSettings": - """Parse the device's ``NAME = value`` settings lines.""" - raw: Dict[str, str] = {} + def from_lines(cls, lines: Iterable[str]) -> "TundraStoreSettings": + """Parse the device's ``NAME = value`` settings output.""" + data = {} for line in lines: if "=" in line: key, _, value = line.partition("=") - raw[key.strip()] = value.strip() - return cls.from_dict(raw, serial, firmware) + data[key.strip()] = value.strip() + return cls.from_dict(data) @classmethod def from_json(cls, path: str) -> "TundraStoreSettings": + """Load from a JSON capture (a flat dict, or one nested under ``settings``).""" with open(path) as f: obj = json.load(f) - if "settings" in obj: - return cls.from_dict(obj["settings"], obj.get("_serial"), obj.get("_firmware")) - return cls.from_dict(obj) + return cls.from_dict(obj.get("settings", obj)) def to_json(self, path: str) -> None: + """Write the settings to a JSON capture, keyed by device ``NAME``.""" + data = {f.name.upper(): getattr(self, f.name) for f in fields(self)} with open(path, "w") as f: - json.dump( - { - "_serial": self.serial_number, - "_firmware": self.firmware_version, - "settings": self.raw, - }, - f, - indent=2, - ) - - def __len__(self) -> int: - return len(self.raw) - - def __getitem__(self, key: str) -> str: - return self.raw[key] - - def __contains__(self, key: str) -> bool: - return key in self.raw + json.dump({"settings": data}, f, indent=2) From 3050b243fec9432362be661de1d108e53aa3f401 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 23:14:55 -0700 Subject: [PATCH 12/23] Collapse settings loaders to a single from_lines The device's settings command is the only source, returning NAME = value text, so keep just from_lines (used by request_settings) and fold the field mapping into it. Drops the unused from_dict/from_json/to_json; direct construction is already available via the dataclass constructor. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/settings.py | 36 ++++++---------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/pylabrobot/highres/tundrastore/settings.py b/pylabrobot/highres/tundrastore/settings.py index 315c972a460..c873e47a595 100644 --- a/pylabrobot/highres/tundrastore/settings.py +++ b/pylabrobot/highres/tundrastore/settings.py @@ -7,10 +7,9 @@ device (or a capture) and is frozen once built. """ -import json import warnings from dataclasses import dataclass, fields -from typing import Any, Iterable, Mapping, Tuple +from typing import Any, Dict, Iterable, Tuple try: from typing import Literal @@ -674,8 +673,14 @@ class TundraStoreSettings: lidvalet_wait_for_lift_rise_ms: int @classmethod - def from_dict(cls, data: Mapping[str, str]) -> "TundraStoreSettings": - """Build from a mapping of device ``NAME`` -> value (the device's own keys).""" + def from_lines(cls, lines: Iterable[str]) -> "TundraStoreSettings": + """Build from the device's ``settings`` output (``NAME = value`` lines).""" + data: Dict[str, str] = {} + for line in lines: + if "=" in line: + key, _, value = line.partition("=") + data[key.strip()] = value.strip() + values = {} missing = [] for f in fields(cls): @@ -698,26 +703,3 @@ def from_dict(cls, data: Mapping[str, str]) -> "TundraStoreSettings": stacklevel=2, ) return cls(**values) - - @classmethod - def from_lines(cls, lines: Iterable[str]) -> "TundraStoreSettings": - """Parse the device's ``NAME = value`` settings output.""" - data = {} - for line in lines: - if "=" in line: - key, _, value = line.partition("=") - data[key.strip()] = value.strip() - return cls.from_dict(data) - - @classmethod - def from_json(cls, path: str) -> "TundraStoreSettings": - """Load from a JSON capture (a flat dict, or one nested under ``settings``).""" - with open(path) as f: - obj = json.load(f) - return cls.from_dict(obj.get("settings", obj)) - - def to_json(self, path: str) -> None: - """Write the settings to a JSON capture, keyed by device ``NAME``.""" - data = {f.name.upper(): getattr(self, f.name) for f in fields(self)} - with open(path, "w") as f: - json.dump({"settings": data}, f, indent=2) From 4263dffedf90a37b8de2aa0feda27c0119d1097f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 23:33:51 -0700 Subject: [PATCH 13/23] Follow #1125 standard: extend AutomatedRetrieval to multiple loading trays #1125 moved storage bookkeeping into AutomatedRetrieval (single loading tray). Extend that standard to multiple loading trays addressed by a 0-based tray_index (defaulting to the first), so multi-nest devices get the shared bookkeeping too: - AutomatedRetrieval takes loading_trays: List[PlateHolder]; fetch_plate_ to_loading_tray / take_in_plate take tray_index and pass it to the backend (which already accepts it). - TundraStore composes the capability with its nests as loading_trays and drops its duplicated bookkeeping (sites, fetch/store, NoFreeSiteError, default_tray); users go through store.retrieval.* with tray_index. - Liconic and Cytomat pass loading_trays=[self.loading_tray]. - Updated the automated-retrieval notebook to loading_trays=[...]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../capabilities/automated-retrieval.ipynb | 2 +- .../automated_retrieval.py | 45 ++++++--- pylabrobot/highres/tundrastore/__init__.py | 4 +- pylabrobot/highres/tundrastore/tundrastore.py | 96 ++----------------- pylabrobot/liconic/liconic.py | 2 +- pylabrobot/thermo_fisher/cytomat/cytomat.py | 2 +- 6 files changed, 44 insertions(+), 107 deletions(-) diff --git a/docs/user_guide/capabilities/automated-retrieval.ipynb b/docs/user_guide/capabilities/automated-retrieval.ipynb index 7d321c9b9e4..d6b739c4a24 100644 --- a/docs/user_guide/capabilities/automated-retrieval.ipynb +++ b/docs/user_guide/capabilities/automated-retrieval.ipynb @@ -18,7 +18,7 @@ { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\nfrom pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\nfrom pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb, PlateCarrier, PlateHolder\n\n# The capability owns the storage racks and the loading tray, and implements the\n# site bookkeeping (free-site counting, lookup, selection). A device that composes\n# AutomatedRetrieval passes these in; here we build them by hand.\nsite = PlateHolder(name=\"site_0\", size_x=127.76, size_y=85.48, size_z=30, pedestal_size_z=0)\nsite.location = Coordinate(0, 0, 0)\nrack = PlateCarrier(name=\"rack_0\", size_x=135, size_y=95, size_z=400, sites={0: site})\nloading_tray = PlateHolder(\n name=\"loading_tray\", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0\n)\n\nretrieval = AutomatedRetrieval(\n backend=AutomatedRetrievalChatterboxBackend(),\n racks=[rack],\n loading_tray=loading_tray,\n)\nawait retrieval._on_setup()\n\n# place a plate into storage so we have something to retrieve\nsite.assign_child_resource(Cor_96_wellplate_360ul_Fb(name=\"my_plate\"))\nprint(\"free sites:\", retrieval.get_num_free_sites())", + "source": "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\nfrom pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\nfrom pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb, PlateCarrier, PlateHolder\n\n# The capability owns the storage racks and the loading tray(s), and implements the\n# site bookkeeping (free-site counting, lookup, selection). A device that composes\n# AutomatedRetrieval passes these in; here we build them by hand. Pass one loading\n# tray per transfer nest; most devices have just one.\nsite = PlateHolder(name=\"site_0\", size_x=127.76, size_y=85.48, size_z=30, pedestal_size_z=0)\nsite.location = Coordinate(0, 0, 0)\nrack = PlateCarrier(name=\"rack_0\", size_x=135, size_y=95, size_z=400, sites={0: site})\nloading_tray = PlateHolder(\n name=\"loading_tray\", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0\n)\n\nretrieval = AutomatedRetrieval(\n backend=AutomatedRetrievalChatterboxBackend(),\n racks=[rack],\n loading_trays=[loading_tray],\n)\nawait retrieval._on_setup()\n\n# place a plate into storage so we have something to retrieve\nsite.assign_child_resource(Cor_96_wellplate_360ul_Fb(name=\"my_plate\"))\nprint(\"free sites:\", retrieval.get_num_free_sites())", "execution_count": null, "outputs": [] }, diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py index e505900664c..5432f17112c 100644 --- a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -19,11 +19,16 @@ class NoFreeSiteError(Exception): class AutomatedRetrieval(Capability): """Automated plate retrieval/storage capability. - Owns the storage racks and the loading tray, and implements the site + Owns the storage racks and the loading tray(s), and implements the site bookkeeping (free-site counting, lookup and selection) shared by all automated storage systems so devices composing this capability do not have to reimplement it. + Most devices have a single loading tray and pass it as a one-element list. + Devices with several transfer nests (e.g. the TundraStore) pass one + :class:`PlateHolder` per nest and address them by ``tray_index`` (0-based, + defaulting to the first tray). + See :doc:`/user_guide/capabilities/automated-retrieval` for a walkthrough. """ @@ -31,17 +36,27 @@ def __init__( self, backend: AutomatedRetrievalBackend, racks: Optional[List[PlateCarrier]] = None, - loading_tray: Optional[PlateHolder] = None, + loading_trays: Optional[List[PlateHolder]] = None, ): super().__init__(backend=backend) self.backend: AutomatedRetrievalBackend = backend self._racks: List[PlateCarrier] = racks if racks is not None else [] - self.loading_tray = loading_tray + self.loading_trays: List[PlateHolder] = loading_trays if loading_trays is not None else [] @property def racks(self) -> List[PlateCarrier]: return self._racks + def _loading_tray(self, tray_index: int) -> PlateHolder: + if not self.loading_trays: + raise RuntimeError("No loading tray configured for this automated retrieval.") + if not 0 <= tray_index < len(self.loading_trays): + raise ValueError( + f"tray_index {tray_index} out of range; this device has " + f"{len(self.loading_trays)} loading tray(s)." + ) + return self.loading_trays[tray_index] + # -- site bookkeeping -- def get_num_free_sites(self) -> int: @@ -83,29 +98,29 @@ def find_random_site(self, plate: Plate) -> PlateHolder: # -- storage operations -- @need_capability_ready - async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: - """Retrieve the plate with the given name from storage onto the loading tray.""" - if self.loading_tray is None: - raise RuntimeError("No loading tray configured for this automated retrieval.") + async def fetch_plate_to_loading_tray(self, plate_name: str, tray_index: int = 0) -> Plate: + """Retrieve the named plate from storage onto loading tray ``tray_index``.""" + tray = self._loading_tray(tray_index) site = self.get_site_by_plate_name(plate_name) plate = cast(Plate, site.resource) - await self.backend.fetch_plate_to_loading_tray(plate) + await self.backend.fetch_plate_to_loading_tray(plate, tray_index=tray_index) plate.unassign() - self.loading_tray.assign_child_resource(plate) + tray.assign_child_resource(plate) return plate @need_capability_ready async def take_in_plate( - self, site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest" + self, + site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest", + tray_index: int = 0, ): - """Take the plate from the loading tray and store it into storage. + """Take the plate from loading tray ``tray_index`` and store it into storage. `site` may be an explicit free `PlateHolder`, or `"smallest"`/`"random"` to let the capability pick a fitting free site. """ - if self.loading_tray is None: - raise RuntimeError("No loading tray configured for this automated retrieval.") - plate = cast(Optional[Plate], self.loading_tray.resource) + tray = self._loading_tray(tray_index) + plate = cast(Optional[Plate], tray.resource) if plate is None: raise ResourceNotFoundError("No plate on the loading tray.") @@ -119,7 +134,7 @@ async def take_in_plate( else: raise ValueError(f"Invalid site: {site}") - await self.backend.store_plate(plate, site) + await self.backend.store_plate(plate, site, tray_index=tray_index) plate.unassign() site.assign_child_resource(plate) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index 39676d9754b..8e7ad9650f7 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -14,4 +14,6 @@ StackerDimensions, VersionInfo, ) -from .tundrastore import NoFreeSiteError, TundraStore +from pylabrobot.capabilities.automated_retrieval import NoFreeSiteError + +from .tundrastore import TundraStore diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/tundrastore/tundrastore.py index bfc19e6edc2..83f153c2c5f 100644 --- a/pylabrobot/highres/tundrastore/tundrastore.py +++ b/pylabrobot/highres/tundrastore/tundrastore.py @@ -1,5 +1,4 @@ -import random -from typing import List, Literal, Optional, Union, cast +from typing import List, Optional from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval from pylabrobot.capabilities.capability import BackendParams @@ -8,10 +7,8 @@ from pylabrobot.device import Device from pylabrobot.resources import ( Coordinate, - Plate, PlateCarrier, PlateHolder, - ResourceNotFoundError, Rotation, ) from pylabrobot.resources.resource import Resource @@ -19,18 +16,15 @@ from .backend import TundraStoreBackend -class NoFreeSiteError(Exception): - pass - - class TundraStore(Resource, Device): """HighRes Biosolutions TundraStore refrigerated plate store. Each rack is a *stacker* (a vertical column of plate slots); plates enter and leave through one of the device's *nests* (transfer stations). The store has - two nests, modeled here as two loading trays — :attr:`nests` ``[0]`` and - ``[1]`` — addressed by the ``tray_index`` argument of the :class:`AutomatedRetrieval` - capability (0-based). ``tray_index=None`` uses :attr:`default_tray`. + two nests, exposed as the loading trays of the :class:`AutomatedRetrieval` + capability (:attr:`retrieval`). Storage bookkeeping and the fetch/store + operations live on the capability; address a particular nest with its + ``tray_index`` (0-based, defaulting to the first nest). """ def __init__( @@ -39,7 +33,6 @@ def __init__( driver: TundraStoreBackend, racks: List[PlateCarrier], nest_locations: List[Coordinate], - default_tray: int = 0, size_x: float = 0, size_y: float = 0, size_z: float = 0, @@ -52,7 +45,6 @@ def __init__( racks: Storage racks; rack *i* maps to device stacker ``i + 1``. nest_locations: One :class:`Coordinate` per transfer nest (the device has two). ``nest_locations[i]`` is the location of nest/tray ``i``. - default_tray: 0-based nest used when a ``tray_index`` argument is omitted. """ Resource.__init__( self, @@ -66,7 +58,6 @@ def __init__( ) Device.__init__(self, driver=driver) self.driver: TundraStoreBackend = driver - self.default_tray = default_tray self.nests: List[PlateHolder] = [] for i, location in enumerate(nest_locations): @@ -80,7 +71,9 @@ def __init__( for rack in self._racks: self.assign_child_resource(rack, location=None) - self.retrieval = AutomatedRetrieval(backend=driver) + self.retrieval = AutomatedRetrieval( + backend=driver, racks=self._racks, loading_trays=self.nests + ) self.tc = TemperatureController(backend=driver) self.humidity = HumidityController(backend=driver) self._capabilities = [self.tc, self.humidity, self.retrieval] @@ -89,82 +82,10 @@ def __init__( def racks(self) -> List[PlateCarrier]: return self._racks - def _tray_index(self, tray_index: Optional[int]) -> int: - idx = self.default_tray if tray_index is None else tray_index - if not 0 <= idx < len(self.nests): - raise ValueError(f"'{self.name}' has trays 0..{len(self.nests) - 1}; got tray_index={idx}.") - return idx - async def setup(self, backend_params: Optional[BackendParams] = None): await super().setup(backend_params=backend_params) await self.driver.set_racks(self._racks) - def get_num_free_sites(self) -> int: - return sum(len(rack.get_free_sites()) for rack in self._racks) - - def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: - for rack in self._racks: - for site in rack.sites.values(): - if site.resource is not None and site.resource.name == plate_name: - return site - raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") - - def _available_sites(self, plate: Plate) -> List[PlateHolder]: - def height(p: Plate) -> float: - return p.get_size_z() + (3 if p.has_lid() else 0) - - available = [ - site - for rack in self._racks - for site in rack.get_free_sites() - if site.get_size_z() >= height(plate) - ] - if not available: - raise NoFreeSiteError(f"No free site in '{self.name}' for plate '{plate.name}'") - return sorted(available, key=lambda s: s.get_size_z()) - - async def fetch_plate_to_nest(self, plate_name: str, tray_index: Optional[int] = None) -> Plate: - """Retrieve a stored plate and place it on a nest (default :attr:`default_tray`).""" - idx = self._tray_index(tray_index) - site = self.get_site_by_plate_name(plate_name) - plate = cast(Plate, site.resource) - await self.retrieval.fetch_plate_to_loading_tray(plate, tray_index=idx) - plate.unassign() - self.nests[idx].assign_child_resource(plate) - return plate - - async def take_in_plate( - self, - site: Union[PlateHolder, Literal["random", "smallest"]], - tray_index: Optional[int] = None, - ): - """Store the plate currently on a nest into a stacker slot. - - Args: - site: Destination slot, or ``"smallest"`` / ``"random"`` to auto-select. - tray_index: Which nest the plate is on (default :attr:`default_tray`). - """ - idx = self._tray_index(tray_index) - plate = cast(Plate, self.nests[idx].resource) - if plate is None: - raise ResourceNotFoundError(f"No plate on nest {idx} of '{self.name}'") - - target: PlateHolder - if site == "smallest": - target = self._available_sites(plate)[0] - elif site == "random": - target = random.choice(self._available_sites(plate)) - elif isinstance(site, PlateHolder): - if site not in self._available_sites(plate): - raise ValueError(f"Site {site.name} is not available for plate {plate.name}") - target = site - else: - raise ValueError(f"Invalid site: {site}") - - await self.retrieval.store_plate(plate, target, tray_index=idx) - plate.unassign() - target.assign_child_resource(plate) - def serialize(self) -> dict: from pylabrobot.serializer import serialize @@ -173,5 +94,4 @@ def serialize(self) -> dict: **Resource.serialize(self), "racks": [rack.serialize() for rack in self._racks], "nest_locations": [serialize(nest.location) for nest in self.nests], - "default_tray": self.default_tray, } diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py index 3e7955ea602..1eb60e43eec 100644 --- a/pylabrobot/liconic/liconic.py +++ b/pylabrobot/liconic/liconic.py @@ -64,7 +64,7 @@ def __init__( self.assign_child_resource(rack, location=None) self.retrieval = AutomatedRetrieval( - backend=backend, racks=self._racks, loading_tray=self.loading_tray + backend=backend, racks=self._racks, loading_trays=[self.loading_tray] ) self.tc = ( TemperatureController(backend=backend) if liconic_model.has_temperature_control else None diff --git a/pylabrobot/thermo_fisher/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/cytomat.py index 9c7c0071c1f..a6d5dcc724b 100644 --- a/pylabrobot/thermo_fisher/cytomat/cytomat.py +++ b/pylabrobot/thermo_fisher/cytomat/cytomat.py @@ -66,7 +66,7 @@ def __init__( self.assign_child_resource(rack, location=None) self.retrieval = AutomatedRetrieval( - backend=driver, racks=self._racks, loading_tray=self.loading_tray + backend=driver, racks=self._racks, loading_trays=[self.loading_tray] ) self.tc = TemperatureController(backend=driver) self.humidity = HumidityController(backend=driver) From be36acaa38999fd425bfd049b4c5c7a738d19b14 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 27 Jun 2026 23:36:26 -0700 Subject: [PATCH 14/23] Fix Liconic/Cytomat hello-world notebooks for #1125 API After #1125 moved storage operations onto AutomatedRetrieval, the device frontends no longer expose take_in_plate / fetch_plate_to_loading_tray / summary. Route those calls through device.retrieval in both hello-world notebooks, and repoint the Cytomat {meth} cross-references to the AutomatedRetrieval capability. (.loading_tray and .racks stay on the device.) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user_guide/liconic/stx/hello-world.ipynb | 8 ++++---- .../user_guide/thermo_fisher/cytomat/hello-world.ipynb | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/user_guide/liconic/stx/hello-world.ipynb b/docs/user_guide/liconic/stx/hello-world.ipynb index f5a5e0648cd..dbaf1351faa 100644 --- a/docs/user_guide/liconic/stx/hello-world.ipynb +++ b/docs/user_guide/liconic/stx/hello-world.ipynb @@ -35,7 +35,7 @@ { "cell_type": "code", "id": "562vs3qn86", - "source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.take_in_plate(\"smallest\") # store in the smallest free site that fits", + "source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.retrieval.take_in_plate(\"smallest\") # store in the smallest free site that fits", "metadata": {}, "execution_count": null, "outputs": [] @@ -49,7 +49,7 @@ { "cell_type": "code", "id": "2w9vsj6i8bp", - "source": "await incubator.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource", + "source": "await incubator.retrieval.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource", "metadata": {}, "execution_count": null, "outputs": [] @@ -63,7 +63,7 @@ { "cell_type": "code", "id": "rl1sabpoa6i", - "source": "await incubator.take_in_plate(\"random\") # random free site\n# await incubator.take_in_plate(racks[3].sites[0]) # specific rack and position", + "source": "await incubator.retrieval.take_in_plate(\"random\") # random free site\n# await incubator.retrieval.take_in_plate(racks[3].sites[0]) # specific rack and position", "metadata": {}, "execution_count": null, "outputs": [] @@ -77,7 +77,7 @@ { "cell_type": "code", "id": "rvqkhqaii3r", - "source": "print(incubator.summary())", + "source": "print(incubator.retrieval.summary())", "metadata": {}, "execution_count": null, "outputs": [] diff --git a/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb b/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb index 8292ff494b0..551915480dc 100644 --- a/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb +++ b/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb @@ -29,13 +29,13 @@ { "cell_type": "markdown", "id": "snc2ihvinf", - "source": "## Storing a plate\n\nPlace a plate on the loading tray and call {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.take_in_plate` to move it into storage. You can specify `\"smallest\"` (smallest free site that fits), `\"random\"`, or an explicit site. See [Automated Retrieval](../../capabilities/automated-retrieval) for the full capability API.", + "source": "## Storing a plate\n\nPlace a plate on the loading tray and call {meth}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval.take_in_plate` (via `cytomat.retrieval`) to move it into storage. You can specify `\"smallest\"` (smallest free site that fits), `\"random\"`, or an explicit site. See [Automated Retrieval](../../capabilities/automated-retrieval) for the full capability API.", "metadata": {} }, { "cell_type": "code", "id": "7mnnfp1pup6", - "source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\ncytomat.loading_tray.assign_child_resource(plate)\n\nawait cytomat.take_in_plate(\"smallest\") # choose the smallest free site\n\n# other options:\n# await cytomat.take_in_plate(\"random\") # random free site\n# await cytomat.take_in_plate(rack[3]) # store at rack position 3", + "source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\ncytomat.loading_tray.assign_child_resource(plate)\n\nawait cytomat.retrieval.take_in_plate(\"smallest\") # choose the smallest free site\n\n# other options:\n# await cytomat.retrieval.take_in_plate(\"random\") # random free site\n# await cytomat.retrieval.take_in_plate(rack[3]) # store at rack position 3", "metadata": {}, "execution_count": null, "outputs": [] @@ -43,13 +43,13 @@ { "cell_type": "markdown", "id": "jtiua5orv2m", - "source": "## Retrieving a plate\n\nUse {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.fetch_plate_to_loading_tray` to move a plate from storage back to the loading tray.", + "source": "## Retrieving a plate\n\nUse {meth}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval.fetch_plate_to_loading_tray` (via `cytomat.retrieval`) to move a plate from storage back to the loading tray.", "metadata": {} }, { "cell_type": "code", "id": "7o3qh6ahz29", - "source": "await cytomat.fetch_plate_to_loading_tray(\"my_plate\")\nretrieved = cytomat.loading_tray.resource", + "source": "await cytomat.retrieval.fetch_plate_to_loading_tray(\"my_plate\")\nretrieved = cytomat.loading_tray.resource", "metadata": {}, "execution_count": null, "outputs": [] @@ -105,7 +105,7 @@ { "cell_type": "code", "id": "6ur6evj2pt", - "source": "print(cytomat.summary())", + "source": "print(cytomat.retrieval.summary())", "metadata": {}, "execution_count": null, "outputs": [] From 7949eb4fad35fa895f14b3b063821743902bbb32 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 15:59:47 -0700 Subject: [PATCH 15/23] TundraStore settings: inline value conversion, drop _coerce helper Coercion mostly returned the raw string unchanged; only int/float fields need conversion. Inline that single expression in from_lines instead of a dedicated helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/settings.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/pylabrobot/highres/tundrastore/settings.py b/pylabrobot/highres/tundrastore/settings.py index c873e47a595..fff33ca71d6 100644 --- a/pylabrobot/highres/tundrastore/settings.py +++ b/pylabrobot/highres/tundrastore/settings.py @@ -9,7 +9,7 @@ import warnings from dataclasses import dataclass, fields -from typing import Any, Dict, Iterable, Tuple +from typing import Dict, Iterable, Tuple try: from typing import Literal @@ -23,14 +23,6 @@ KNOWN_MACHINE_TYPES: Tuple[str, ...] = ("SteriStore2",) -def _coerce(typ: Any, raw: Any) -> Any: - if typ is int: - return int(raw) - if typ is float: - return float(raw) - return str(raw) - - @dataclass(frozen=True) class TundraStoreSettings: """All on-device settings, one typed attribute per device key.""" @@ -688,7 +680,7 @@ def from_lines(cls, lines: Iterable[str]) -> "TundraStoreSettings": if key not in data: missing.append(key) continue - values[f.name] = _coerce(f.type, data[key]) + values[f.name] = f.type(data[key]) if f.type in (int, float) else data[key] if missing: raise ValueError( f"settings is missing {len(missing)} expected key(s): " From 63138bad92329a13c4baa3fed356edfd62a110bb Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:06:51 -0700 Subject: [PATCH 16/23] TundraStore: replace DoorState/NestState enums with string literals DoorState and NestState are now Literal aliases with lowercase values plus DOOR_STATES/NEST_STATES valid-value tuples, matching the MachineType pattern. The backend lower-cases the device's status output and validates against the tuples (falling back to "unknown") instead of constructing enums. Rename standard.py to types.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/__init__.py | 2 +- pylabrobot/highres/tundrastore/backend.py | 20 +++++++------- .../highres/tundrastore/backend_tests.py | 11 ++++---- .../tundrastore/{standard.py => types.py} | 27 ++++++++----------- 4 files changed, 26 insertions(+), 34 deletions(-) rename pylabrobot/highres/tundrastore/{standard.py => types.py} (58%) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index 8e7ad9650f7..d59a3c2bbe6 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -7,7 +7,7 @@ TundraStoreFault, ) from .settings import MachineType, TundraStoreSettings -from .standard import ( +from .types import ( DoorState, EnvironmentParameter, NestState, diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index ed946c8bf3d..1a30c9524fe 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -1,7 +1,7 @@ import asyncio import logging from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, cast from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend from pylabrobot.capabilities.capability import BackendParams @@ -19,7 +19,9 @@ left_unsafe, ) from .settings import TundraStoreSettings -from .standard import ( +from .types import ( + DOOR_STATES, + NEST_STATES, DoorState, EnvironmentParameter, NestState, @@ -227,10 +229,8 @@ async def request_door_status(self) -> Dict[str, DoorState]: """Parsed ``doorstatus`` output, keyed by door name.""" doors: Dict[str, DoorState] = {} for name, value in self._parse_kv(await self.send_command("doorstatus")).items(): - try: - doors[name] = DoorState(value) - except ValueError: - doors[name] = DoorState.UNKNOWN + state = value.lower() + doors[name] = cast(DoorState, state) if state in DOOR_STATES else "unknown" return doors async def request_nest_status(self) -> Dict[int, NestState]: @@ -241,10 +241,8 @@ async def request_nest_status(self) -> Dict[int, NestState]: nest = int(key) except ValueError: continue - try: - nests[nest] = NestState(value) - except ValueError: - nests[nest] = NestState.UNKNOWN + state = value.lower() + nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" return nests async def spatula_request_is_holding(self) -> bool: @@ -259,7 +257,7 @@ async def nest_request_is_holding(self, nest: int) -> bool: ``OCCUPIED``; anything other than ``CLEAR`` counts as holding. """ state = (await self.request_nest_status()).get(nest) - return state is not None and state is not NestState.CLEAR + return state is not None and state != "clear" async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> bool: """Probe whether a plate is present in ``(stacker, slot)`` by attempting a diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 697d7896b26..28169302318 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -7,7 +7,6 @@ TundraStoreError, TundraStoreFault, ) -from pylabrobot.highres.tundrastore.standard import DoorState, NestState # Real responses captured from a TundraStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. @@ -108,14 +107,14 @@ async def test_is_homed(self): async def test_door_status(self): doors = await self.backend.request_door_status() - self.assertEqual(doors["User Door"], DoorState.CLOSED) - self.assertEqual(doors["SEAL"], DoorState.CLOSING) - self.assertEqual(doors["RO1"], DoorState.CLOSING) - self.assertFalse(all(state is DoorState.CLOSED for state in doors.values())) + self.assertEqual(doors["User Door"], "closed") + self.assertEqual(doors["SEAL"], "closing") + self.assertEqual(doors["RO1"], "closing") + self.assertFalse(all(state == "closed" for state in doors.values())) async def test_nest_status(self): nests = await self.backend.request_nest_status() - self.assertEqual(nests, {1: NestState.CLEAR, 2: NestState.CLEAR}) + self.assertEqual(nests, {1: "clear", 2: "clear"}) async def test_plate_on_spatula(self): self.assertFalse(await self.backend.spatula_request_is_holding()) diff --git a/pylabrobot/highres/tundrastore/standard.py b/pylabrobot/highres/tundrastore/types.py similarity index 58% rename from pylabrobot/highres/tundrastore/standard.py rename to pylabrobot/highres/tundrastore/types.py index c13faa6ef66..c5a7a7e64ff 100644 --- a/pylabrobot/highres/tundrastore/standard.py +++ b/pylabrobot/highres/tundrastore/types.py @@ -1,24 +1,19 @@ -import enum from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, Tuple +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal # type: ignore -class DoorState(enum.Enum): - """State of a single TundraStore door, as reported by ``doorstatus``.""" - OPEN = "OPEN" - CLOSED = "CLOSED" - OPENING = "OPENING" - CLOSING = "CLOSING" - UNKNOWN = "UNKNOWN" +# State of a single TundraStore door, as reported by ``doorstatus``. +DoorState = Literal["open", "closed", "opening", "closing", "unknown"] +DOOR_STATES: Tuple[DoorState, ...] = ("open", "closed", "opening", "closing", "unknown") - -class NestState(enum.Enum): - """State of a transfer nest, as reported by ``neststatus``.""" - - CLEAR = "CLEAR" - OCCUPIED = "OCCUPIED" - UNKNOWN = "UNKNOWN" +# State of a transfer nest, as reported by ``neststatus``. +NestState = Literal["clear", "occupied", "unknown"] +NEST_STATES: Tuple[NestState, ...] = ("clear", "occupied", "unknown") @dataclass From 8d6124ad09086988fbf3a4b18cfe6b0ae4870630 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:23:33 -0700 Subject: [PATCH 17/23] TundraStore: split monolithic backend into driver + per-capability backends The single TundraStoreBackend mixed transport, retrieval, temperature and humidity. Split it so a HighResSampleStorageDriver owns the transport (send_command + shared version/environment queries) and the per-capability backends it constructs: - HighResSampleStorageAutomatedRetrievalBackend (storage/motion) - HighResSampleStorageTemperatureControllerBackend - HighResSampleStorageHumidityControllerBackend Capability backends build commands on driver.send_command (no duplication on the driver). The device pulls them off driver.{automated_retrieval,temperature, humidity}; the chatterbox is now a driver that fakes only the transport and owns the real capability backends. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/tundrastore/__init__.py | 9 +- pylabrobot/highres/tundrastore/backend.py | 451 ++++++++++-------- .../highres/tundrastore/backend_tests.py | 88 ++-- pylabrobot/highres/tundrastore/chatterbox.py | 110 ++--- pylabrobot/highres/tundrastore/errors.py | 6 +- pylabrobot/highres/tundrastore/tundrastore.py | 14 +- 6 files changed, 365 insertions(+), 313 deletions(-) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/tundrastore/__init__.py index d59a3c2bbe6..963489b6fe5 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/tundrastore/__init__.py @@ -1,5 +1,10 @@ -from .backend import TundraStoreBackend -from .chatterbox import TundraStoreChatterboxBackend +from .backend import ( + HighResSampleStorageAutomatedRetrievalBackend, + HighResSampleStorageDriver, + HighResSampleStorageHumidityControllerBackend, + HighResSampleStorageTemperatureControllerBackend, +) +from .chatterbox import HighResSampleStorageChatterboxDriver from .errors import ( PlateNotFoundError, TundraStoreAbortedError, diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/tundrastore/backend.py index 1a30c9524fe..9e7d3f984c1 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/tundrastore/backend.py @@ -9,7 +9,7 @@ from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend from pylabrobot.device import Driver from pylabrobot.io.socket import Socket -from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder from .errors import ( PlateNotFoundError, @@ -42,20 +42,18 @@ ACK_TOKEN = "ACK!" -class TundraStoreBackend( - AutomatedRetrievalBackend, - TemperatureControllerBackend, - HumidityControllerBackend, - Driver, -): - """Backend for the HighRes Biosolutions TundraStore automated plate store. +def _parse_kv(lines: List[str]) -> Dict[str, str]: + """Parse ``Key: value`` lines into a dict (first colon splits).""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out - The TundraStore (also sold/branded as "SteriStore") exposes a text-based - remote-control server over TCP, port 1000. Commands are case-sensitive, - space-separated, terminated with ``\\r\\n``. Each command is answered with an - ``ACK!`` echo, optional data lines, then exactly one completion line - (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the TundraStore User Manual, - section "Message Formatting". + +class HighResSampleStorageAutomatedRetrievalBackend(AutomatedRetrievalBackend): + """Plate storage/motion (automated retrieval) for a HighRes sample store. Plates are stored in a refrigerated carousel of *stackers*, each holding a number of *slots*. An external robot hands plates to/from one of the device's @@ -63,158 +61,36 @@ class TundraStoreBackend( and a (stacker, slot). The low-level :meth:`pick` / :meth:`place` take those three indices directly. - The TundraStore has two nests. They are exposed through the multi-tray - :class:`AutomatedRetrieval` capability: its ``tray_index`` argument is a 0-based nest - index (tray ``i`` -> device nest ``i + 1``), and ``tray_index=None`` selects - :attr:`loading_tray_nest`. :meth:`pick` / :meth:`place` address a nest by its - 1-based device number directly. - """ - - @dataclass - class SetupParams(BackendParams): - """Optional parameters for :meth:`setup`.""" + The store has two nests, exposed through the multi-tray + :class:`AutomatedRetrieval` capability: its ``tray_index`` argument is a + 0-based nest index (tray ``i`` -> device nest ``i + 1``), and ``tray_index=None`` + selects :attr:`loading_tray_nest`. - home_on_setup: bool = False + All commands are issued through the owning :class:`HighResSampleStorageDriver`. + """ def __init__( self, - host: str, - port: int = 1000, - read_timeout: float = 30.0, - motion_timeout: float = 240.0, + driver: "HighResSampleStorageDriver", loading_tray_nest: int = 1, + num_nests: int = 2, ): - """ - Args: - host: IP address of the TundraStore. The factory default is - ``192.168.127.60``; all HighRes devices also answer on the backdoor - ``10.253.253.253``. - port: Remote-control server port (always 1000). - read_timeout: Timeout (s) for query/status commands. - motion_timeout: Timeout (s) for long-running motion commands - (``home``, ``pick``, ``place``, door moves). - loading_tray_nest: Which nest the :class:`AutomatedRetrieval` capability - uses as its loading tray (1 or 2). - """ super().__init__() - self.io = Socket( - human_readable_device_name="HighRes TundraStore", - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=read_timeout, - ) - self._read_timeout = read_timeout - self._motion_timeout = motion_timeout + self._driver = driver self.loading_tray_nest = loading_tray_nest - self.num_nests = 2 + self.num_nests = num_nests # Slide (Y) below this is "retracted"; a spatula stuck in a stacker sits at # the ~256mm slide-in depth, home is 0. Used by is_parked()/recover(). self._retracted_y_max = 50.0 - self._command_lock = asyncio.Lock() - # stacker/slot lookup for the AutomatedRetrieval capability, built from racks. + # stacker/slot lookup, built from racks by set_racks(). self._site_locations: Dict[str, Tuple[int, int]] = {} - def serialize(self) -> dict: - return { - **super().serialize(), - "host": self.io._host, - "port": self.io._port, - "read_timeout": self._read_timeout, - "motion_timeout": self._motion_timeout, - "loading_tray_nest": self.loading_tray_nest, - } - - # --- lifecycle ------------------------------------------------------------ - - async def setup(self, backend_params: Optional[BackendParams] = None): - if backend_params is None: - backend_params = TundraStoreBackend.SetupParams() - if not isinstance(backend_params, TundraStoreBackend.SetupParams): - raise TypeError(f"backend_params must be {TundraStoreBackend.SetupParams}") - - await self.io.setup() - version = await self.request_version() - logger.info( - "Connected to %s (serial %s, firmware %s)", - version.product_name, - version.serial_number, - version.firmware_version, - ) - if backend_params.home_on_setup: - await self.home() - - async def stop(self): - await self.io.stop() - - # --- transport ------------------------------------------------------------ - - async def _readline(self, timeout: Optional[float]) -> str: - raw = await self.io.readuntil(b"\n", timeout=timeout) - return raw.decode("ascii", errors="replace").rstrip("\r\n") - - async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: - """Send a command and return its data lines (those between the ``ACK!`` echo - and the completion line). - - Raises: - TundraStoreError: if the device replies ``ERROR!``. - TundraStoreAbortedError: if the device replies ``ABORTED!``. - """ - if timeout is None: - timeout = self._read_timeout - async with self._command_lock: - await self.io.write(command.encode("ascii") + b"\r\n") - - data_lines: List[str] = [] - completion: Optional[str] = None - seen_ack = False - while completion is None: - line = await self._readline(timeout) - if line.startswith(ACK_TOKEN) and not seen_ack: - seen_ack = True - continue - if line.startswith(COMPLETION_TOKENS): - completion = line - break - data_lines.append(line) - - if completion.startswith(COMPLETION_ERROR): - # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* - # the ERROR! completion, so they are already collected in data_lines. - error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines - raise TundraStoreError(command, error_lines) - if completion.startswith(COMPLETION_ABORTED): - raise TundraStoreAbortedError(command) - assert completion.startswith(COMPLETION_OK) - return data_lines - - @staticmethod - def _parse_kv(lines: List[str]) -> Dict[str, str]: - """Parse ``Key: value`` lines into a dict (first colon splits).""" - out: Dict[str, str] = {} - for line in lines: - if ":" in line: - key, _, value = line.partition(":") - out[key.strip()] = value.strip() - return out - # --- queries (verified against firmware 3.0.0.119) ------------------------ - async def request_version(self) -> VersionInfo: - raw = self._parse_kv(await self.send_command("version")) - return VersionInfo( - product_name=raw.get("Product Name"), - serial_number=raw.get("Serial Number"), - firmware_version=raw.get("Firmware Version"), - firmware_build=raw.get("Firmware Build"), - raw=raw, - ) - async def request_axis_positions(self) -> Dict[str, float]: """Return the ``status`` report: carousel/theta/Y/Z positions.""" out: Dict[str, float] = {} - for key, value in self._parse_kv(await self.send_command("status")).items(): + for key, value in _parse_kv(await self._driver.send_command("status")).items(): try: out[key] = float(value) except ValueError: @@ -222,13 +98,13 @@ async def request_axis_positions(self) -> Dict[str, float]: return out async def is_homed(self) -> bool: - lines = await self.send_command("homedstatus") + lines = await self._driver.send_command("homedstatus") return any(line.strip().lower() == "homed" for line in lines) async def request_door_status(self) -> Dict[str, DoorState]: """Parsed ``doorstatus`` output, keyed by door name.""" doors: Dict[str, DoorState] = {} - for name, value in self._parse_kv(await self.send_command("doorstatus")).items(): + for name, value in _parse_kv(await self._driver.send_command("doorstatus")).items(): state = value.lower() doors[name] = cast(DoorState, state) if state in DOOR_STATES else "unknown" return doors @@ -236,7 +112,7 @@ async def request_door_status(self) -> Dict[str, DoorState]: async def request_nest_status(self) -> Dict[int, NestState]: """Parsed ``neststatus`` output, keyed by nest number.""" nests: Dict[int, NestState] = {} - for key, value in self._parse_kv(await self.send_command("neststatus")).items(): + for key, value in _parse_kv(await self._driver.send_command("neststatus")).items(): try: nest = int(key) except ValueError: @@ -247,7 +123,7 @@ async def request_nest_status(self) -> Dict[int, NestState]: async def spatula_request_is_holding(self) -> bool: """Whether a plate is currently held on the spatula (``platestatus``).""" - lines = await self.send_command("platestatus") + lines = await self._driver.send_command("platestatus") return not any("NO_PLATE" in line for line in lines) async def nest_request_is_holding(self, nest: int) -> bool: @@ -275,39 +151,11 @@ async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> boo except PlateNotFoundError: return False - async def request_environment(self) -> Dict[str, EnvironmentParameter]: - """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. - - Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels - (e.g. the gas tank pressures) report only a current value. - """ - out: Dict[str, EnvironmentParameter] = {} - for line in await self.send_command("environmentstatus"): - if ":" not in line: - continue - name, _, rest = line.partition(":") - parts = rest.strip().rstrip(":").split("/") - try: - current = float(parts[0]) - except (ValueError, IndexError): - continue - - def _opt(i: int) -> Optional[float]: - try: - return float(parts[i]) - except (ValueError, IndexError): - return None - - out[name.strip()] = EnvironmentParameter( - name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) - ) - return out - async def get_stacker_dimensions(self) -> List[StackerDimensions]: """Parse ``getstackerdimensions`` (``: ``).""" dims: List[StackerDimensions] = [] - for line in await self.send_command("getstackerdimensions"): + for line in await self._driver.send_command("getstackerdimensions"): key, _, rest = line.partition(":") try: stacker = int(key) @@ -327,7 +175,7 @@ async def get_stacker_dimensions(self) -> List[StackerDimensions]: async def request_settings(self) -> TundraStoreSettings: """Read the device's full settings file (``NAME = value`` pairs) into a frozen :class:`TundraStoreSettings`.""" - lines = await self.send_command("settings", timeout=self._read_timeout) + lines = await self._driver.send_command("settings", timeout=self._driver.read_timeout) return TundraStoreSettings.from_lines(lines) async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: @@ -341,7 +189,7 @@ async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> Li command = f"barcode {stacker}" if slot is not None: command += f" {slot}" - return await self.send_command(command, timeout=self._motion_timeout) + return await self._driver.send_command(command, timeout=self._driver.motion_timeout) # --- motion --------------------------------------------------------------- @@ -349,7 +197,7 @@ async def home(self): """Home the system. The first step closes all doors, which requires the pneumatic supply (clean dry air >80 psi); without it this raises :class:`TundraStoreError` ("Unable to close all doors").""" - await self.send_command("home", timeout=self._motion_timeout) + await self._driver.send_command("home", timeout=self._driver.motion_timeout) async def is_parked(self) -> bool: """Whether the machine is genuinely safe to move: homed AND the spatula @@ -377,11 +225,11 @@ async def recover(self) -> bool: for _ in range(3): for command in ("enable", "spatulaout"): try: - await self.send_command(command, timeout=self._motion_timeout) + await self._driver.send_command(command, timeout=self._driver.motion_timeout) except TundraStoreError: pass try: - await self.send_command("home", timeout=self._motion_timeout) + await self._driver.send_command("home", timeout=self._driver.motion_timeout) except TundraStoreError: pass if await self.is_parked(): @@ -407,7 +255,7 @@ async def pick(self, stacker: int, slot: int, nest: int, close_door: bool = True """ command = f"pick {stacker} {slot} {nest}" try: - await self.send_command(command, timeout=self._motion_timeout) + await self._driver.send_command(command, timeout=self._driver.motion_timeout) except TundraStoreError as exc: if left_unsafe(exc.error_lines) or not await self.is_homed(): raise TundraStoreFault(command, exc.error_lines) from exc @@ -426,26 +274,28 @@ async def place(self, stacker: int, slot: int, nest: int, close_door: bool = Tru operation (handy when the cold environment doesn't matter). The default leaves it sealed. """ - await self.send_command(f"place {stacker} {slot} {nest}", timeout=self._motion_timeout) + await self._driver.send_command( + f"place {stacker} {slot} {nest}", timeout=self._driver.motion_timeout + ) if not close_door: await self.open_all_doors() async def open_all_doors(self): - await self.send_command("openalldoors", timeout=self._motion_timeout) + await self._driver.send_command("openalldoors", timeout=self._driver.motion_timeout) async def close_all_doors(self): - await self.send_command("closealldoors", timeout=self._motion_timeout) + await self._driver.send_command("closealldoors", timeout=self._driver.motion_timeout) async def abort(self): """Stop current machine operations. ``clear_abort`` is required afterward.""" - await self.send_command("abort") + await self._driver.send_command("abort") async def clear_abort(self): - await self.send_command("clearabort") + await self._driver.send_command("clearabort") # --- AutomatedRetrieval capability ---------------------------------------- - async def set_racks(self, racks): + async def set_racks(self, racks: List[PlateCarrier]): """Register the storage racks so the capability can resolve a plate/site to a ``(stacker, slot)``. Rack *i* (0-based) maps to stacker ``i + 1``; site *j* within a rack maps to slot ``j + 1``.""" @@ -466,7 +316,9 @@ def _nest_for_tray(self, tray_index: Optional[int]) -> int: if tray_index is None: return self.loading_tray_nest if not 0 <= tray_index < self.num_nests: - raise ValueError(f"TundraStore has trays 0..{self.num_nests - 1}; got tray_index={tray_index}.") + raise ValueError( + f"sample store has trays 0..{self.num_nests - 1}; got tray_index={tray_index}." + ) return tray_index + 1 async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): @@ -480,35 +332,230 @@ async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optiona stacker, slot = self._locate(site) await self.place(stacker, slot, self._nest_for_tray(tray_index)) - # --- TemperatureController capability ------------------------------------- + +class HighResSampleStorageTemperatureControllerBackend(TemperatureControllerBackend): + """Temperature control for a HighRes sample store (refrigerated, -20 to 4 C).""" + + def __init__(self, driver: "HighResSampleStorageDriver"): + super().__init__() + self._driver = driver @property def supports_active_cooling(self) -> bool: - return True # refrigerated store (-20 to 4 C) + return True async def request_current_temperature(self) -> float: - env = await self.request_environment() + env = await self._driver.request_environment() if "TEMP" not in env: raise TundraStoreError("environmentstatus", ["no TEMP channel reported"]) return env["TEMP"].current async def set_temperature(self, temperature: float): - await self.send_command(f"environmentset TEMP {temperature}") + await self._driver.send_command(f"environmentset TEMP {temperature}") async def deactivate(self): - await self.send_command("environment TEMP off") + await self._driver.send_command("environment TEMP off") - # --- HumidityController capability (read-only monitoring) ----------------- + +class HighResSampleStorageHumidityControllerBackend(HumidityControllerBackend): + """Humidity monitoring for a HighRes sample store (read-only; no active control).""" + + def __init__(self, driver: "HighResSampleStorageDriver"): + super().__init__() + self._driver = driver @property def supports_humidity_control(self) -> bool: return False async def request_current_humidity(self) -> float: - env = await self.request_environment() + env = await self._driver.request_environment() if "RH" not in env: raise TundraStoreError("environmentstatus", ["no RH channel reported"]) return env["RH"].current / 100.0 async def set_humidity(self, humidity: float): - raise NotImplementedError("The TundraStore does not support active humidity control.") + raise NotImplementedError("HighRes sample stores do not support active humidity control.") + + +class HighResSampleStorageDriver(Driver): + """Transport for HighRes Biosolutions sample stores (TundraStore / SteriStore + / AmbiStore). + + The store exposes a text-based remote-control server over TCP, port 1000. + Commands are case-sensitive, space-separated, terminated with ``\\r\\n``. Each + command is answered with an ``ACK!`` echo, optional data lines, then exactly + one completion line (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the User + Manual, section "Message Formatting". + + :meth:`send_command` is the shared primitive. The driver owns the per-capability + backends (:attr:`automated_retrieval`, :attr:`temperature`, :attr:`humidity`), + which build their commands on top of it. + """ + + @dataclass + class SetupParams(BackendParams): + """Optional parameters for :meth:`setup`.""" + + home_on_setup: bool = False + + def __init__( + self, + host: str, + port: int = 1000, + read_timeout: float = 30.0, + motion_timeout: float = 240.0, + loading_tray_nest: int = 1, + num_nests: int = 2, + ): + """ + Args: + host: IP address of the store. The factory default is ``192.168.127.60``; + all HighRes devices also answer on the backdoor ``10.253.253.253``. + port: Remote-control server port (always 1000). + read_timeout: Timeout (s) for query/status commands. + motion_timeout: Timeout (s) for long-running motion commands + (``home``, ``pick``, ``place``, door moves). + loading_tray_nest: Which nest the :class:`AutomatedRetrieval` capability + uses as its default loading tray (1 or 2). + """ + super().__init__() + self.io = Socket( + human_readable_device_name="HighRes sample store", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=read_timeout, + ) + self._read_timeout = read_timeout + self._motion_timeout = motion_timeout + self._command_lock = asyncio.Lock() + + self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( + self, loading_tray_nest=loading_tray_nest, num_nests=num_nests + ) + self.temperature = HighResSampleStorageTemperatureControllerBackend(self) + self.humidity = HighResSampleStorageHumidityControllerBackend(self) + + @property + def read_timeout(self) -> float: + return self._read_timeout + + @property + def motion_timeout(self) -> float: + return self._motion_timeout + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.io._host, + "port": self.io._port, + "read_timeout": self._read_timeout, + "motion_timeout": self._motion_timeout, + "loading_tray_nest": self.automated_retrieval.loading_tray_nest, + } + + # --- lifecycle ------------------------------------------------------------ + + async def setup(self, backend_params: Optional[BackendParams] = None): + if backend_params is None: + backend_params = HighResSampleStorageDriver.SetupParams() + if not isinstance(backend_params, HighResSampleStorageDriver.SetupParams): + raise TypeError(f"backend_params must be {HighResSampleStorageDriver.SetupParams}") + + await self.io.setup() + version = await self.request_version() + logger.info( + "Connected to %s (serial %s, firmware %s)", + version.product_name, + version.serial_number, + version.firmware_version, + ) + if backend_params.home_on_setup: + await self.automated_retrieval.home() + + async def stop(self): + await self.io.stop() + + # --- transport ------------------------------------------------------------ + + async def _readline(self, timeout: Optional[float]) -> str: + raw = await self.io.readuntil(b"\n", timeout=timeout) + return raw.decode("ascii", errors="replace").rstrip("\r\n") + + async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: + """Send a command and return its data lines (those between the ``ACK!`` echo + and the completion line). + + Raises: + TundraStoreError: if the device replies ``ERROR!``. + TundraStoreAbortedError: if the device replies ``ABORTED!``. + """ + if timeout is None: + timeout = self._read_timeout + async with self._command_lock: + await self.io.write(command.encode("ascii") + b"\r\n") + + data_lines: List[str] = [] + completion: Optional[str] = None + seen_ack = False + while completion is None: + line = await self._readline(timeout) + if line.startswith(ACK_TOKEN) and not seen_ack: + seen_ack = True + continue + if line.startswith(COMPLETION_TOKENS): + completion = line + break + data_lines.append(line) + + if completion.startswith(COMPLETION_ERROR): + # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* + # the ERROR! completion, so they are already collected in data_lines. + error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines + raise TundraStoreError(command, error_lines) + if completion.startswith(COMPLETION_ABORTED): + raise TundraStoreAbortedError(command) + assert completion.startswith(COMPLETION_OK) + return data_lines + + # --- shared device queries ------------------------------------------------ + + async def request_version(self) -> VersionInfo: + raw = _parse_kv(await self.send_command("version")) + return VersionInfo( + product_name=raw.get("Product Name"), + serial_number=raw.get("Serial Number"), + firmware_version=raw.get("Firmware Version"), + firmware_build=raw.get("Firmware Build"), + raw=raw, + ) + + async def request_environment(self) -> Dict[str, EnvironmentParameter]: + """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. + + Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels + (e.g. the gas tank pressures) report only a current value. Shared by the + temperature and humidity capability backends. + """ + out: Dict[str, EnvironmentParameter] = {} + for line in await self.send_command("environmentstatus"): + if ":" not in line: + continue + name, _, rest = line.partition(":") + parts = rest.strip().rstrip(":").split("/") + try: + current = float(parts[0]) + except (ValueError, IndexError): + continue + + def _opt(i: int, parts=parts) -> Optional[float]: + try: + return float(parts[i]) + except (ValueError, IndexError): + return None + + out[name.strip()] = EnvironmentParameter( + name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) + ) + return out diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/tundrastore/backend_tests.py index 28169302318..f30c3c8d83b 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/tundrastore/backend_tests.py @@ -1,7 +1,7 @@ import unittest from typing import Dict, List -from pylabrobot.highres.tundrastore.backend import TundraStoreBackend +from pylabrobot.highres.tundrastore.backend import HighResSampleStorageDriver from pylabrobot.highres.tundrastore.errors import ( PlateNotFoundError, TundraStoreError, @@ -86,41 +86,42 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: class TundraStoreBackendTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.backend = TundraStoreBackend(host="10.253.253.253") + self.driver = HighResSampleStorageDriver(host="10.253.253.253") self.socket = FakeSocket(CAPTURES) - self.backend.io = self.socket # type: ignore[assignment] + self.driver.io = self.socket # type: ignore[assignment] + self.retrieval = self.driver.automated_retrieval async def test_send_command_strips_ack_and_completion(self): - data = await self.backend.send_command("neststatus") + data = await self.driver.send_command("neststatus") self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) self.assertEqual(self.socket.written, ["neststatus"]) async def test_version(self): - v = await self.backend.request_version() + v = await self.driver.request_version() self.assertEqual(v.product_name, "SteriStore") self.assertEqual(v.serial_number, "HRB-2209-35148") self.assertEqual(v.firmware_version, "3.0.0.119") self.assertEqual(v.firmware_build, "D9BE232A") async def test_is_homed(self): - self.assertFalse(await self.backend.is_homed()) + self.assertFalse(await self.retrieval.is_homed()) async def test_door_status(self): - doors = await self.backend.request_door_status() + doors = await self.retrieval.request_door_status() self.assertEqual(doors["User Door"], "closed") self.assertEqual(doors["SEAL"], "closing") self.assertEqual(doors["RO1"], "closing") self.assertFalse(all(state == "closed" for state in doors.values())) async def test_nest_status(self): - nests = await self.backend.request_nest_status() + nests = await self.retrieval.request_nest_status() self.assertEqual(nests, {1: "clear", 2: "clear"}) async def test_plate_on_spatula(self): - self.assertFalse(await self.backend.spatula_request_is_holding()) + self.assertFalse(await self.retrieval.spatula_request_is_holding()) async def test_environment_parsing(self): - env = await self.backend.request_environment() + env = await self.driver.request_environment() self.assertAlmostEqual(env["TEMP"].current, 21.9) self.assertIsNotNone(env["TEMP"].setpoint) assert env["TEMP"].setpoint is not None # narrow for type checker @@ -131,15 +132,15 @@ async def test_environment_parsing(self): self.assertIsNone(env["TANK1"].setpoint) async def test_temperature_capability_reads_temp_channel(self): - self.assertAlmostEqual(await self.backend.request_current_temperature(), 21.9) - self.assertTrue(self.backend.supports_active_cooling) + self.assertAlmostEqual(await self.driver.temperature.request_current_temperature(), 21.9) + self.assertTrue(self.driver.temperature.supports_active_cooling) async def test_humidity_capability_reads_rh_as_fraction(self): - self.assertAlmostEqual(await self.backend.request_current_humidity(), 0.547) - self.assertFalse(self.backend.supports_humidity_control) + self.assertAlmostEqual(await self.driver.humidity.request_current_humidity(), 0.547) + self.assertFalse(self.driver.humidity.supports_humidity_control) async def test_stacker_dimensions(self): - dims = await self.backend.get_stacker_dimensions() + dims = await self.retrieval.get_stacker_dimensions() self.assertEqual(dims[0].stacker, 1) self.assertEqual(dims[0].slot_count, 0) self.assertEqual(dims[1].stacker, 2) @@ -148,30 +149,30 @@ async def test_stacker_dimensions(self): async def test_home_error_raises_with_stack_detail(self): with self.assertRaises(TundraStoreError) as ctx: - await self.backend.home() + await self.retrieval.home() self.assertIn("Unable to close all doors", str(ctx.exception)) self.assertEqual(ctx.exception.command, "home") async def test_pick_formats_command(self): self.socket.captures["pick 3 12 1"] = ["ACK! pick 3 12 1 99", "OK! pick 3 12 1 99"] - await self.backend.pick(3, 12, 1) + await self.retrieval.pick(3, 12, 1) self.assertEqual(self.socket.written, ["pick 3 12 1"]) def test_tray_maps_to_nest(self): # 0-based capability tray -> 1-based device nest; None uses the default. - self.assertEqual(self.backend._nest_for_tray(None), 1) - self.assertEqual(self.backend._nest_for_tray(0), 1) - self.assertEqual(self.backend._nest_for_tray(1), 2) + self.assertEqual(self.retrieval._nest_for_tray(None), 1) + self.assertEqual(self.retrieval._nest_for_tray(0), 1) + self.assertEqual(self.retrieval._nest_for_tray(1), 2) with self.assertRaises(ValueError): - self.backend._nest_for_tray(2) + self.retrieval._nest_for_tray(2) def test_default_tray_follows_loading_tray_nest(self): - backend = TundraStoreBackend(host="10.253.253.253", loading_tray_nest=2) - self.assertEqual(backend._nest_for_tray(None), 2) + driver = HighResSampleStorageDriver(host="10.253.253.253", loading_tray_nest=2) + self.assertEqual(driver.automated_retrieval._nest_for_tray(None), 2) async def test_set_humidity_unsupported(self): with self.assertRaises(NotImplementedError): - await self.backend.set_humidity(0.5) + await self.driver.humidity.set_humidity(0.5) def _ok(command: str, cid: int) -> List[str]: @@ -208,7 +209,8 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: class TundraStoreRecoveryTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.backend = TundraStoreBackend(host="10.253.253.253") + self.driver = HighResSampleStorageDriver(host="10.253.253.253") + self.retrieval = self.driver.automated_retrieval async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): # The store reports "No plate detected" and stays homed (graceful empty). @@ -223,9 +225,9 @@ async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): ("homedstatus", ["ACK! homedstatus 51", "homed", "OK! homedstatus 51"]), ] ) - self.backend.io = sock # type: ignore[assignment] + self.driver.io = sock # type: ignore[assignment] with self.assertRaises(PlateNotFoundError): - await self.backend.pick(5, 12, 1) + await self.retrieval.pick(5, 12, 1) # classified by state, no recovery motion issued self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus"]) @@ -241,9 +243,9 @@ async def test_top_slot_stuck_raises_fault_despite_homed_lie(self): "ERROR! pick 5 24 1 60", ] sock = ScriptedSocket([("pick 5 24 1", stuck)]) - self.backend.io = sock # type: ignore[assignment] + self.driver.io = sock # type: ignore[assignment] with self.assertRaises(TundraStoreFault): - await self.backend.pick(5, 24, 1) + await self.retrieval.pick(5, 24, 1) self.assertEqual(sock.commands, ["pick 5 24 1"]) async def test_dehomed_pick_raises_fault(self): @@ -259,9 +261,9 @@ async def test_dehomed_pick_raises_fault(self): ("homedstatus", ["ACK! homedstatus 71", "not homed", "OK! homedstatus 71"]), ] ) - self.backend.io = sock # type: ignore[assignment] + self.driver.io = sock # type: ignore[assignment] with self.assertRaises(TundraStoreFault): - await self.backend.pick(5, 24, 1) + await self.retrieval.pick(5, 24, 1) self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) def _homed(self, cid: int) -> List[str]: @@ -279,8 +281,8 @@ def _status(self, cid: int, y: float) -> List[str]: async def test_is_parked_catches_the_homed_lie(self): # homedstatus says homed, but the spatula is stuck extended (Y=256) -> NOT parked. sock = ScriptedSocket([("homedstatus", self._homed(1)), ("status", self._status(2, 255.9999))]) - self.backend.io = sock # type: ignore[assignment] - self.assertFalse(await self.backend.is_parked()) + self.driver.io = sock # type: ignore[assignment] + self.assertFalse(await self.retrieval.is_parked()) async def test_recover_always_retracts_and_rehomes(self): # recover() must issue retract+home even when homedstatus already says homed @@ -294,8 +296,8 @@ async def test_recover_always_retracts_and_rehomes(self): ("status", self._status(5, 0.0)), ] ) - self.backend.io = sock # type: ignore[assignment] - self.assertTrue(await self.backend.recover()) + self.driver.io = sock # type: ignore[assignment] + self.assertTrue(await self.retrieval.recover()) self.assertEqual(sock.commands, ["enable", "spatulaout", "home", "homedstatus", "status"]) async def test_recover_retries_until_parked(self): @@ -314,13 +316,13 @@ async def test_recover_retries_until_parked(self): ("status", self._status(10, 0.0)), # now retracted ] ) - self.backend.io = sock # type: ignore[assignment] - self.assertTrue(await self.backend.recover()) + self.driver.io = sock # type: ignore[assignment] + self.assertTrue(await self.retrieval.recover()) async def test_place_default_leaves_doors_sealed(self): sock = ScriptedSocket([("place 2 5 1", _ok("place 2 5 1", 1))]) - self.backend.io = sock # type: ignore[assignment] - await self.backend.place(2, 5, 1) # close_door=True default + self.driver.io = sock # type: ignore[assignment] + await self.retrieval.place(2, 5, 1) # close_door=True default self.assertEqual(sock.commands, ["place 2 5 1"]) async def test_place_close_door_false_reopens(self): @@ -330,8 +332,8 @@ async def test_place_close_door_false_reopens(self): ("openalldoors", _ok("openalldoors", 2)), ] ) - self.backend.io = sock # type: ignore[assignment] - await self.backend.place(2, 5, 1, close_door=False) + self.driver.io = sock # type: ignore[assignment] + await self.retrieval.place(2, 5, 1, close_door=False) self.assertEqual(sock.commands, ["place 2 5 1", "openalldoors"]) async def test_pick_close_door_false_reopens(self): @@ -341,8 +343,8 @@ async def test_pick_close_door_false_reopens(self): ("openalldoors", _ok("openalldoors", 2)), ] ) - self.backend.io = sock # type: ignore[assignment] - await self.backend.pick(2, 5, 1, close_door=False) + self.driver.io = sock # type: ignore[assignment] + await self.retrieval.pick(2, 5, 1, close_door=False) self.assertEqual(sock.commands, ["pick 2 5 1", "openalldoors"]) diff --git a/pylabrobot/highres/tundrastore/chatterbox.py b/pylabrobot/highres/tundrastore/chatterbox.py index 24c9af2828a..15f185b6f39 100644 --- a/pylabrobot/highres/tundrastore/chatterbox.py +++ b/pylabrobot/highres/tundrastore/chatterbox.py @@ -1,73 +1,69 @@ import logging -from typing import Optional +from typing import Dict, List, Optional -from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend -from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend from pylabrobot.device import Driver -from pylabrobot.resources import Plate, PlateHolder -logger = logging.getLogger(__name__) +from .backend import ( + HighResSampleStorageAutomatedRetrievalBackend, + HighResSampleStorageDriver, + HighResSampleStorageHumidityControllerBackend, + HighResSampleStorageTemperatureControllerBackend, +) +from .types import EnvironmentParameter, VersionInfo +logger = logging.getLogger(__name__) -class TundraStoreChatterboxBackend( - AutomatedRetrievalBackend, - TemperatureControllerBackend, - HumidityControllerBackend, - Driver, -): - """Device-free TundraStore backend that logs calls instead of talking to - hardware. Useful for testing protocols and resource assignment offline.""" - def __init__(self, temperature: float = 4.0, humidity: float = 0.5): - super().__init__() +class HighResSampleStorageChatterboxDriver(HighResSampleStorageDriver): + """Device-free driver that logs commands instead of talking to hardware. + + Owns the real per-capability backends, so it exercises their command-building + logic; only the transport (:meth:`send_command`) and the shared device queries + are faked. Useful for testing protocols and resource assignment offline. + """ + + def __init__( + self, + temperature: float = 4.0, + humidity: float = 0.5, + loading_tray_nest: int = 1, + num_nests: int = 2, + ): + Driver.__init__(self) + self.io = None # type: ignore[assignment] + self._read_timeout = 30.0 + self._motion_timeout = 240.0 self._temperature = temperature self._humidity = humidity + self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( + self, loading_tray_nest=loading_tray_nest, num_nests=num_nests + ) + self.temperature = HighResSampleStorageTemperatureControllerBackend(self) + self.humidity = HighResSampleStorageHumidityControllerBackend(self) + async def setup(self, backend_params: Optional[BackendParams] = None): - logger.info("[tundrastore] setup") + logger.info("[chatterbox] setup") async def stop(self): - logger.info("[tundrastore] stop") - - async def home(self): - logger.info("[tundrastore] home") - - async def pick(self, stacker: int, slot: int, nest: int): - logger.info("[tundrastore] pick stacker=%d slot=%d nest=%d", stacker, slot, nest) - - async def place(self, stacker: int, slot: int, nest: int): - logger.info("[tundrastore] place stacker=%d slot=%d nest=%d", stacker, slot, nest) - - async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None): - logger.info("[tundrastore] fetch plate %s to loading tray %s", plate.name, tray_index) - - async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): - logger.info( - "[tundrastore] store plate %s at site %s (tray %s)", plate.name, site.name, tray_index + logger.info("[chatterbox] stop") + + async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: + logger.info("[chatterbox] %s", command) + return [] + + async def request_version(self) -> VersionInfo: + return VersionInfo( + product_name="HighRes sample store", + serial_number="CHATTERBOX", + firmware_version="0.0.0", + firmware_build=None, + raw={}, ) - @property - def supports_active_cooling(self) -> bool: - return True - - async def request_current_temperature(self) -> float: - return self._temperature - - async def set_temperature(self, temperature: float): - logger.info("[tundrastore] set temperature %.1f C", temperature) - self._temperature = temperature - - async def deactivate(self): - logger.info("[tundrastore] deactivate temperature control") - - @property - def supports_humidity_control(self) -> bool: - return False - - async def request_current_humidity(self) -> float: - return self._humidity - - async def set_humidity(self, humidity: float): - raise NotImplementedError("The TundraStore does not support active humidity control.") + async def request_environment(self) -> Dict[str, EnvironmentParameter]: + return { + "TEMP": EnvironmentParameter(name="TEMP", current=self._temperature), + "RH": EnvironmentParameter(name="RH", current=self._humidity * 100.0), + } diff --git a/pylabrobot/highres/tundrastore/errors.py b/pylabrobot/highres/tundrastore/errors.py index 11d85aad3e5..e9dfe563d9d 100644 --- a/pylabrobot/highres/tundrastore/errors.py +++ b/pylabrobot/highres/tundrastore/errors.py @@ -40,7 +40,8 @@ class TundraStoreFault(TundraStoreError): """A motion command faulted and left the machine UNHOMED/extended. The canonical trigger is picking an empty *top* slot. The machine is not - usable until recovered — call :meth:`TundraStoreBackend.recover` (retract the + usable until recovered — call + :meth:`HighResSampleStorageAutomatedRetrievalBackend.recover` (retract the spatula and re-home) before issuing further motion. """ @@ -63,6 +64,7 @@ def __init__(self, command: str, error_lines: List[str]): def left_unsafe(error_lines: List[str]) -> bool: """Whether an error stack indicates the machine was left unsafe (spatula - extended / unhomed), requiring :meth:`TundraStoreBackend.recover`.""" + extended / unhomed), requiring + :meth:`HighResSampleStorageAutomatedRetrievalBackend.recover`.""" blob = " ".join(error_lines).lower() return any(sig in blob for sig in _UNSAFE_SIGNATURES) diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/tundrastore/tundrastore.py index 83f153c2c5f..5a9cdcc4d05 100644 --- a/pylabrobot/highres/tundrastore/tundrastore.py +++ b/pylabrobot/highres/tundrastore/tundrastore.py @@ -13,7 +13,7 @@ ) from pylabrobot.resources.resource import Resource -from .backend import TundraStoreBackend +from .backend import HighResSampleStorageDriver class TundraStore(Resource, Device): @@ -30,7 +30,7 @@ class TundraStore(Resource, Device): def __init__( self, name: str, - driver: TundraStoreBackend, + driver: HighResSampleStorageDriver, racks: List[PlateCarrier], nest_locations: List[Coordinate], size_x: float = 0, @@ -57,7 +57,7 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self.driver: TundraStoreBackend = driver + self.driver: HighResSampleStorageDriver = driver self.nests: List[PlateHolder] = [] for i, location in enumerate(nest_locations): @@ -72,10 +72,10 @@ def __init__( self.assign_child_resource(rack, location=None) self.retrieval = AutomatedRetrieval( - backend=driver, racks=self._racks, loading_trays=self.nests + backend=driver.automated_retrieval, racks=self._racks, loading_trays=self.nests ) - self.tc = TemperatureController(backend=driver) - self.humidity = HumidityController(backend=driver) + self.tc = TemperatureController(backend=driver.temperature) + self.humidity = HumidityController(backend=driver.humidity) self._capabilities = [self.tc, self.humidity, self.retrieval] @property @@ -84,7 +84,7 @@ def racks(self) -> List[PlateCarrier]: async def setup(self, backend_params: Optional[BackendParams] = None): await super().setup(backend_params=backend_params) - await self.driver.set_racks(self._racks) + await self.driver.automated_retrieval.set_racks(self._racks) def serialize(self) -> dict: from pylabrobot.serializer import serialize From b5a5ef3b295f1d1e43b21bc57319443438befa89 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:25:49 -0700 Subject: [PATCH 18/23] Rename highres.tundrastore -> highres.sample_storage; add device subclasses The TundraStore, SteriStore and AmbiStore are the same machine family behind a shared port-1000 API. Rename the module to highres.sample_storage and put all implementation on a private base device _HighResSampleStorage; the concrete devices are thin subclasses: - TundraStore / SteriStore: refrigerated, full retrieval + temperature/humidity. - AmbiStore: WORK IN PROGRESS (warns on init); ambient, so it wires only the retrieval capability and no environment control (unverified against hardware). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__init__.py | 10 +-- .../backend.py | 38 +++++----- .../backend_tests.py | 20 +++--- .../chatterbox.py | 0 .../{tundrastore => sample_storage}/errors.py | 10 +-- .../sample_storage.py} | 70 +++++++++++++++---- .../settings.py | 6 +- .../{tundrastore => sample_storage}/types.py | 0 8 files changed, 99 insertions(+), 55 deletions(-) rename pylabrobot/highres/{tundrastore => sample_storage}/__init__.py (69%) rename pylabrobot/highres/{tundrastore => sample_storage}/backend.py (94%) rename pylabrobot/highres/{tundrastore => sample_storage}/backend_tests.py (95%) rename pylabrobot/highres/{tundrastore => sample_storage}/chatterbox.py (100%) rename pylabrobot/highres/{tundrastore => sample_storage}/errors.py (88%) rename pylabrobot/highres/{tundrastore/tundrastore.py => sample_storage/sample_storage.py} (52%) rename pylabrobot/highres/{tundrastore => sample_storage}/settings.py (98%) rename pylabrobot/highres/{tundrastore => sample_storage}/types.py (100%) diff --git a/pylabrobot/highres/tundrastore/__init__.py b/pylabrobot/highres/sample_storage/__init__.py similarity index 69% rename from pylabrobot/highres/tundrastore/__init__.py rename to pylabrobot/highres/sample_storage/__init__.py index 963489b6fe5..6949e083d0f 100644 --- a/pylabrobot/highres/tundrastore/__init__.py +++ b/pylabrobot/highres/sample_storage/__init__.py @@ -7,11 +7,11 @@ from .chatterbox import HighResSampleStorageChatterboxDriver from .errors import ( PlateNotFoundError, - TundraStoreAbortedError, - TundraStoreError, - TundraStoreFault, + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, ) -from .settings import MachineType, TundraStoreSettings +from .settings import MachineType, HighResSampleStorageSettings from .types import ( DoorState, EnvironmentParameter, @@ -21,4 +21,4 @@ ) from pylabrobot.capabilities.automated_retrieval import NoFreeSiteError -from .tundrastore import TundraStore +from .sample_storage import AmbiStore, SteriStore, TundraStore diff --git a/pylabrobot/highres/tundrastore/backend.py b/pylabrobot/highres/sample_storage/backend.py similarity index 94% rename from pylabrobot/highres/tundrastore/backend.py rename to pylabrobot/highres/sample_storage/backend.py index 9e7d3f984c1..240e5ebd03c 100644 --- a/pylabrobot/highres/tundrastore/backend.py +++ b/pylabrobot/highres/sample_storage/backend.py @@ -13,12 +13,12 @@ from .errors import ( PlateNotFoundError, - TundraStoreAbortedError, - TundraStoreError, - TundraStoreFault, + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, left_unsafe, ) -from .settings import TundraStoreSettings +from .settings import HighResSampleStorageSettings from .types import ( DOOR_STATES, NEST_STATES, @@ -172,11 +172,11 @@ async def get_stacker_dimensions(self) -> List[StackerDimensions]: continue return dims - async def request_settings(self) -> TundraStoreSettings: + async def request_settings(self) -> HighResSampleStorageSettings: """Read the device's full settings file (``NAME = value`` pairs) into a - frozen :class:`TundraStoreSettings`.""" + frozen :class:`HighResSampleStorageSettings`.""" lines = await self._driver.send_command("settings", timeout=self._driver.read_timeout) - return TundraStoreSettings.from_lines(lines) + return HighResSampleStorageSettings.from_lines(lines) async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: """Scan a stacker (or a single slot) for barcodes. @@ -196,7 +196,7 @@ async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> Li async def home(self): """Home the system. The first step closes all doors, which requires the pneumatic supply (clean dry air >80 psi); without it this raises - :class:`TundraStoreError` ("Unable to close all doors").""" + :class:`HighResSampleStorageError` ("Unable to close all doors").""" await self._driver.send_command("home", timeout=self._driver.motion_timeout) async def is_parked(self) -> bool: @@ -226,11 +226,11 @@ async def recover(self) -> bool: for command in ("enable", "spatulaout"): try: await self._driver.send_command(command, timeout=self._driver.motion_timeout) - except TundraStoreError: + except HighResSampleStorageError: pass try: await self._driver.send_command("home", timeout=self._driver.motion_timeout) - except TundraStoreError: + except HighResSampleStorageError: pass if await self.is_parked(): return True @@ -245,7 +245,7 @@ async def pick(self, stacker: int, slot: int, nest: int, close_door: bool = True - :class:`PlateNotFoundError` — the slot was empty ("No plate detected") and the store retracted cleanly; the machine is safe to keep using. - - :class:`TundraStoreFault` — the machine was left unsafe (spatula extended + - :class:`HighResSampleStorageFault` — the machine was left unsafe (spatula extended / unhomed), e.g. an empty *top* slot where the firmware can't complete its safe-travel retract. Call :meth:`recover` before any further motion. @@ -256,9 +256,9 @@ async def pick(self, stacker: int, slot: int, nest: int, close_door: bool = True command = f"pick {stacker} {slot} {nest}" try: await self._driver.send_command(command, timeout=self._driver.motion_timeout) - except TundraStoreError as exc: + except HighResSampleStorageError as exc: if left_unsafe(exc.error_lines) or not await self.is_homed(): - raise TundraStoreFault(command, exc.error_lines) from exc + raise HighResSampleStorageFault(command, exc.error_lines) from exc if any("no plate detected" in line.lower() for line in exc.error_lines): raise PlateNotFoundError(command, exc.error_lines) from exc raise @@ -347,7 +347,7 @@ def supports_active_cooling(self) -> bool: async def request_current_temperature(self) -> float: env = await self._driver.request_environment() if "TEMP" not in env: - raise TundraStoreError("environmentstatus", ["no TEMP channel reported"]) + raise HighResSampleStorageError("environmentstatus", ["no TEMP channel reported"]) return env["TEMP"].current async def set_temperature(self, temperature: float): @@ -371,7 +371,7 @@ def supports_humidity_control(self) -> bool: async def request_current_humidity(self) -> float: env = await self._driver.request_environment() if "RH" not in env: - raise TundraStoreError("environmentstatus", ["no RH channel reported"]) + raise HighResSampleStorageError("environmentstatus", ["no RH channel reported"]) return env["RH"].current / 100.0 async def set_humidity(self, humidity: float): @@ -488,8 +488,8 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L and the completion line). Raises: - TundraStoreError: if the device replies ``ERROR!``. - TundraStoreAbortedError: if the device replies ``ABORTED!``. + HighResSampleStorageError: if the device replies ``ERROR!``. + HighResSampleStorageAbortedError: if the device replies ``ABORTED!``. """ if timeout is None: timeout = self._read_timeout @@ -513,9 +513,9 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* # the ERROR! completion, so they are already collected in data_lines. error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines - raise TundraStoreError(command, error_lines) + raise HighResSampleStorageError(command, error_lines) if completion.startswith(COMPLETION_ABORTED): - raise TundraStoreAbortedError(command) + raise HighResSampleStorageAbortedError(command) assert completion.startswith(COMPLETION_OK) return data_lines diff --git a/pylabrobot/highres/tundrastore/backend_tests.py b/pylabrobot/highres/sample_storage/backend_tests.py similarity index 95% rename from pylabrobot/highres/tundrastore/backend_tests.py rename to pylabrobot/highres/sample_storage/backend_tests.py index f30c3c8d83b..27a509bc8ef 100644 --- a/pylabrobot/highres/tundrastore/backend_tests.py +++ b/pylabrobot/highres/sample_storage/backend_tests.py @@ -1,11 +1,11 @@ import unittest from typing import Dict, List -from pylabrobot.highres.tundrastore.backend import HighResSampleStorageDriver -from pylabrobot.highres.tundrastore.errors import ( +from pylabrobot.highres.sample_storage.backend import HighResSampleStorageDriver +from pylabrobot.highres.sample_storage.errors import ( PlateNotFoundError, - TundraStoreError, - TundraStoreFault, + HighResSampleStorageError, + HighResSampleStorageFault, ) # Real responses captured from a TundraStore (firmware 3.0.0.119, serial @@ -84,7 +84,7 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: return self._queue.pop(0).encode("ascii") + b"\r\n" -class TundraStoreBackendTests(unittest.IsolatedAsyncioTestCase): +class HighResSampleStorageBackendTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.driver = HighResSampleStorageDriver(host="10.253.253.253") self.socket = FakeSocket(CAPTURES) @@ -148,7 +148,7 @@ async def test_stacker_dimensions(self): self.assertEqual(dims[1].slot_count, 24) async def test_home_error_raises_with_stack_detail(self): - with self.assertRaises(TundraStoreError) as ctx: + with self.assertRaises(HighResSampleStorageError) as ctx: await self.retrieval.home() self.assertIn("Unable to close all doors", str(ctx.exception)) self.assertEqual(ctx.exception.command, "home") @@ -207,7 +207,7 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: return self._queue.pop(0).encode("ascii") + b"\r\n" -class TundraStoreRecoveryTests(unittest.IsolatedAsyncioTestCase): +class HighResSampleStorageRecoveryTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.driver = HighResSampleStorageDriver(host="10.253.253.253") self.retrieval = self.driver.automated_retrieval @@ -234,7 +234,7 @@ async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): async def test_top_slot_stuck_raises_fault_despite_homed_lie(self): # Empty TOP slot: "No plate detected" but the spatula is left extended and # the firmware reports "unsafe for rotation" while homedstatus still says - # homed. The "unsafe" signal must win -> TundraStoreFault (no homedstatus + # homed. The "unsafe" signal must win -> HighResSampleStorageFault (no homedstatus # query needed, the signature short-circuits). stuck = [ "ACK! pick 5 24 1 60", @@ -244,7 +244,7 @@ async def test_top_slot_stuck_raises_fault_despite_homed_lie(self): ] sock = ScriptedSocket([("pick 5 24 1", stuck)]) self.driver.io = sock # type: ignore[assignment] - with self.assertRaises(TundraStoreFault): + with self.assertRaises(HighResSampleStorageFault): await self.retrieval.pick(5, 24, 1) self.assertEqual(sock.commands, ["pick 5 24 1"]) @@ -262,7 +262,7 @@ async def test_dehomed_pick_raises_fault(self): ] ) self.driver.io = sock # type: ignore[assignment] - with self.assertRaises(TundraStoreFault): + with self.assertRaises(HighResSampleStorageFault): await self.retrieval.pick(5, 24, 1) self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) diff --git a/pylabrobot/highres/tundrastore/chatterbox.py b/pylabrobot/highres/sample_storage/chatterbox.py similarity index 100% rename from pylabrobot/highres/tundrastore/chatterbox.py rename to pylabrobot/highres/sample_storage/chatterbox.py diff --git a/pylabrobot/highres/tundrastore/errors.py b/pylabrobot/highres/sample_storage/errors.py similarity index 88% rename from pylabrobot/highres/tundrastore/errors.py rename to pylabrobot/highres/sample_storage/errors.py index e9dfe563d9d..1966cbccd87 100644 --- a/pylabrobot/highres/tundrastore/errors.py +++ b/pylabrobot/highres/sample_storage/errors.py @@ -1,7 +1,7 @@ from typing import List -class TundraStoreError(Exception): +class HighResSampleStorageError(Exception): """A command returned an ``ERROR!`` completion status. The TundraStore reports failures as an error stack: the completion line is @@ -17,7 +17,7 @@ def __init__(self, command: str, error_lines: List[str]): super().__init__(f"'{command}' failed: {detail}") -class TundraStoreAbortedError(Exception): +class HighResSampleStorageAbortedError(Exception): """A command returned an ``ABORTED!`` completion status (e.g. after ``abort``).""" def __init__(self, command: str): @@ -25,18 +25,18 @@ def __init__(self, command: str): super().__init__(f"'{command}' was aborted") -class PlateNotFoundError(TundraStoreError): +class PlateNotFoundError(HighResSampleStorageError): """A pick found no plate in the target slot ("No plate detected"). This is the normal *empty slot* outcome: the store's height detector reports the absence and the machine stays homed and operational — not a fault. - Contrast with :class:`TundraStoreFault` (the machine de-homed). Note that an + Contrast with :class:`HighResSampleStorageFault` (the machine de-homed). Note that an empty *top* slot raises a fault instead, because the firmware can't complete its safe-travel retract from the topmost position. """ -class TundraStoreFault(TundraStoreError): +class HighResSampleStorageFault(HighResSampleStorageError): """A motion command faulted and left the machine UNHOMED/extended. The canonical trigger is picking an empty *top* slot. The machine is not diff --git a/pylabrobot/highres/tundrastore/tundrastore.py b/pylabrobot/highres/sample_storage/sample_storage.py similarity index 52% rename from pylabrobot/highres/tundrastore/tundrastore.py rename to pylabrobot/highres/sample_storage/sample_storage.py index 5a9cdcc4d05..df71a90b491 100644 --- a/pylabrobot/highres/tundrastore/tundrastore.py +++ b/pylabrobot/highres/sample_storage/sample_storage.py @@ -1,3 +1,4 @@ +import warnings from typing import List, Optional from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval @@ -16,17 +17,25 @@ from .backend import HighResSampleStorageDriver -class TundraStore(Resource, Device): - """HighRes Biosolutions TundraStore refrigerated plate store. +class _HighResSampleStorage(Resource, Device): + """Base device for HighRes Biosolutions sample stores. - Each rack is a *stacker* (a vertical column of plate slots); plates enter and - leave through one of the device's *nests* (transfer stations). The store has - two nests, exposed as the loading trays of the :class:`AutomatedRetrieval` - capability (:attr:`retrieval`). Storage bookkeeping and the fetch/store - operations live on the capability; address a particular nest with its - ``tray_index`` (0-based, defaulting to the first nest). + The TundraStore, SteriStore and AmbiStore are the same machine family behind a + shared port-1000 API, so all of the implementation lives here and the concrete + devices are thin subclasses. Each rack is a *stacker* (a vertical column of + plate slots); plates enter and leave through one of the device's *nests* + (transfer stations), exposed as the loading trays of the + :class:`AutomatedRetrieval` capability (:attr:`retrieval`). Storage bookkeeping + and the fetch/store operations live on the capability; address a particular + nest with its ``tray_index`` (0-based, defaulting to the first nest). + + Subclasses set :attr:`_model_name` and :attr:`_has_environment_control` (the + latter controls whether the temperature/humidity capabilities are wired). """ + _model_name: str = "HighResSampleStorage" + _has_environment_control: bool = True + def __init__( self, name: str, @@ -38,7 +47,7 @@ def __init__( size_z: float = 0, rotation: Optional[Rotation] = None, category: Optional[str] = "plate_store", - model: Optional[str] = "TundraStore", + model: Optional[str] = None, ): """ Args: @@ -54,7 +63,7 @@ def __init__( size_z=size_z, rotation=rotation, category=category, - model=model, + model=model or self._model_name, ) Device.__init__(self, driver=driver) self.driver: HighResSampleStorageDriver = driver @@ -74,9 +83,12 @@ def __init__( self.retrieval = AutomatedRetrieval( backend=driver.automated_retrieval, racks=self._racks, loading_trays=self.nests ) - self.tc = TemperatureController(backend=driver.temperature) - self.humidity = HumidityController(backend=driver.humidity) - self._capabilities = [self.tc, self.humidity, self.retrieval] + self._capabilities = [self.retrieval] + + if self._has_environment_control: + self.tc = TemperatureController(backend=driver.temperature) + self.humidity = HumidityController(backend=driver.humidity) + self._capabilities = [self.tc, self.humidity, self.retrieval] @property def racks(self) -> List[PlateCarrier]: @@ -95,3 +107,35 @@ def serialize(self) -> dict: "racks": [rack.serialize() for rack in self._racks], "nest_locations": [serialize(nest.location) for nest in self.nests], } + + +class TundraStore(_HighResSampleStorage): + """HighRes Biosolutions TundraStore refrigerated plate store.""" + + _model_name = "TundraStore" + + +class SteriStore(_HighResSampleStorage): + """HighRes Biosolutions SteriStore plate store (same API as the TundraStore).""" + + _model_name = "SteriStore" + + +class AmbiStore(_HighResSampleStorage): + """HighRes Biosolutions AmbiStore plate store. + + WORK IN PROGRESS: the AmbiStore is ambient (no refrigeration), so it exposes + only the retrieval capability — no temperature/humidity control. Whether it + has any environment control at all is not yet confirmed against hardware. + """ + + _model_name = "AmbiStore" + _has_environment_control = False + + def __init__(self, *args, **kwargs): + warnings.warn( + "AmbiStore support is a work in progress and unverified against hardware; " + "it currently exposes only the retrieval capability (no environment control).", + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/pylabrobot/highres/tundrastore/settings.py b/pylabrobot/highres/sample_storage/settings.py similarity index 98% rename from pylabrobot/highres/tundrastore/settings.py rename to pylabrobot/highres/sample_storage/settings.py index fff33ca71d6..6d3ba32d5ef 100644 --- a/pylabrobot/highres/tundrastore/settings.py +++ b/pylabrobot/highres/sample_storage/settings.py @@ -3,7 +3,7 @@ The device exposes its full calibration/configuration via the ``settings`` command as ``NAME = value`` text. Each key is surfaced here as one explicitly typed attribute (the device ``NAME`` lower-cased); types are inferred from the -device's own values. A :class:`TundraStoreSettings` is loaded whole from the +device's own values. A :class:`HighResSampleStorageSettings` is loaded whole from the device (or a capture) and is frozen once built. """ @@ -24,7 +24,7 @@ @dataclass(frozen=True) -class TundraStoreSettings: +class HighResSampleStorageSettings: """All on-device settings, one typed attribute per device key.""" product_name: str @@ -665,7 +665,7 @@ class TundraStoreSettings: lidvalet_wait_for_lift_rise_ms: int @classmethod - def from_lines(cls, lines: Iterable[str]) -> "TundraStoreSettings": + def from_lines(cls, lines: Iterable[str]) -> "HighResSampleStorageSettings": """Build from the device's ``settings`` output (``NAME = value`` lines).""" data: Dict[str, str] = {} for line in lines: diff --git a/pylabrobot/highres/tundrastore/types.py b/pylabrobot/highres/sample_storage/types.py similarity index 100% rename from pylabrobot/highres/tundrastore/types.py rename to pylabrobot/highres/sample_storage/types.py From 45c86c3292006836298c5b803f89e40f95405db9 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:37:27 -0700 Subject: [PATCH 19/23] sample_storage: split backend.py into a driver/ package, one class per file backend.py held the driver and all three capability backends. Split it into a driver/ subpackage: - driver/protocol.py - wire tokens + parse_kv (shared helpers) - driver/automated_retrieval.py - driver/temperature.py - driver/humidity.py - driver/driver.py - HighResSampleStorageDriver (owns the three backends) Capability backends reference the driver via a TYPE_CHECKING import to avoid a runtime import cycle; the driver/ __init__ re-exports the four classes so the public import paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/sample_storage/__init__.py | 2 +- .../highres/sample_storage/backend_tests.py | 2 +- .../highres/sample_storage/chatterbox.py | 2 +- .../highres/sample_storage/driver/__init__.py | 4 + .../automated_retrieval.py} | 285 +----------------- .../highres/sample_storage/driver/driver.py | 207 +++++++++++++ .../highres/sample_storage/driver/humidity.py | 29 ++ .../highres/sample_storage/driver/protocol.py | 24 ++ .../sample_storage/driver/temperature.py | 32 ++ .../highres/sample_storage/sample_storage.py | 2 +- 10 files changed, 311 insertions(+), 278 deletions(-) create mode 100644 pylabrobot/highres/sample_storage/driver/__init__.py rename pylabrobot/highres/sample_storage/{backend.py => driver/automated_retrieval.py} (55%) create mode 100644 pylabrobot/highres/sample_storage/driver/driver.py create mode 100644 pylabrobot/highres/sample_storage/driver/humidity.py create mode 100644 pylabrobot/highres/sample_storage/driver/protocol.py create mode 100644 pylabrobot/highres/sample_storage/driver/temperature.py diff --git a/pylabrobot/highres/sample_storage/__init__.py b/pylabrobot/highres/sample_storage/__init__.py index 6949e083d0f..2aaaf28bdc9 100644 --- a/pylabrobot/highres/sample_storage/__init__.py +++ b/pylabrobot/highres/sample_storage/__init__.py @@ -1,4 +1,4 @@ -from .backend import ( +from .driver import ( HighResSampleStorageAutomatedRetrievalBackend, HighResSampleStorageDriver, HighResSampleStorageHumidityControllerBackend, diff --git a/pylabrobot/highres/sample_storage/backend_tests.py b/pylabrobot/highres/sample_storage/backend_tests.py index 27a509bc8ef..f72978978e4 100644 --- a/pylabrobot/highres/sample_storage/backend_tests.py +++ b/pylabrobot/highres/sample_storage/backend_tests.py @@ -1,7 +1,7 @@ import unittest from typing import Dict, List -from pylabrobot.highres.sample_storage.backend import HighResSampleStorageDriver +from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver from pylabrobot.highres.sample_storage.errors import ( PlateNotFoundError, HighResSampleStorageError, diff --git a/pylabrobot/highres/sample_storage/chatterbox.py b/pylabrobot/highres/sample_storage/chatterbox.py index 15f185b6f39..d6bb82d7c29 100644 --- a/pylabrobot/highres/sample_storage/chatterbox.py +++ b/pylabrobot/highres/sample_storage/chatterbox.py @@ -4,7 +4,7 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver -from .backend import ( +from .driver import ( HighResSampleStorageAutomatedRetrievalBackend, HighResSampleStorageDriver, HighResSampleStorageHumidityControllerBackend, diff --git a/pylabrobot/highres/sample_storage/driver/__init__.py b/pylabrobot/highres/sample_storage/driver/__init__.py new file mode 100644 index 00000000000..a262b404768 --- /dev/null +++ b/pylabrobot/highres/sample_storage/driver/__init__.py @@ -0,0 +1,4 @@ +from .automated_retrieval import HighResSampleStorageAutomatedRetrievalBackend +from .driver import HighResSampleStorageDriver +from .humidity import HighResSampleStorageHumidityControllerBackend +from .temperature import HighResSampleStorageTemperatureControllerBackend diff --git a/pylabrobot/highres/sample_storage/backend.py b/pylabrobot/highres/sample_storage/driver/automated_retrieval.py similarity index 55% rename from pylabrobot/highres/sample_storage/backend.py rename to pylabrobot/highres/sample_storage/driver/automated_retrieval.py index 240e5ebd03c..a67b5dfde10 100644 --- a/pylabrobot/highres/sample_storage/backend.py +++ b/pylabrobot/highres/sample_storage/driver/automated_retrieval.py @@ -1,55 +1,20 @@ -import asyncio -import logging -from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, cast from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend -from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.device import Driver -from pylabrobot.io.socket import Socket from pylabrobot.resources import Plate, PlateCarrier, PlateHolder -from .errors import ( - PlateNotFoundError, - HighResSampleStorageAbortedError, +from ..errors import ( HighResSampleStorageError, HighResSampleStorageFault, + PlateNotFoundError, left_unsafe, ) -from .settings import HighResSampleStorageSettings -from .types import ( - DOOR_STATES, - NEST_STATES, - DoorState, - EnvironmentParameter, - NestState, - StackerDimensions, - VersionInfo, -) - -logger = logging.getLogger(__name__) +from ..settings import HighResSampleStorageSettings +from ..types import DOOR_STATES, NEST_STATES, DoorState, NestState, StackerDimensions +from .protocol import parse_kv -# Completion-status tokens that terminate a command's reply (see the manual, -# "Message Formatting"). Every command ends with exactly one of these. -COMPLETION_OK = "OK!" -COMPLETION_ABORTED = "ABORTED!" -COMPLETION_ERROR = "ERROR!" -COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) - -# Immediate command-receipt echo prefix. -ACK_TOKEN = "ACK!" - - -def _parse_kv(lines: List[str]) -> Dict[str, str]: - """Parse ``Key: value`` lines into a dict (first colon splits).""" - out: Dict[str, str] = {} - for line in lines: - if ":" in line: - key, _, value = line.partition(":") - out[key.strip()] = value.strip() - return out +if TYPE_CHECKING: + from .driver import HighResSampleStorageDriver class HighResSampleStorageAutomatedRetrievalBackend(AutomatedRetrievalBackend): @@ -90,7 +55,7 @@ def __init__( async def request_axis_positions(self) -> Dict[str, float]: """Return the ``status`` report: carousel/theta/Y/Z positions.""" out: Dict[str, float] = {} - for key, value in _parse_kv(await self._driver.send_command("status")).items(): + for key, value in parse_kv(await self._driver.send_command("status")).items(): try: out[key] = float(value) except ValueError: @@ -104,7 +69,7 @@ async def is_homed(self) -> bool: async def request_door_status(self) -> Dict[str, DoorState]: """Parsed ``doorstatus`` output, keyed by door name.""" doors: Dict[str, DoorState] = {} - for name, value in _parse_kv(await self._driver.send_command("doorstatus")).items(): + for name, value in parse_kv(await self._driver.send_command("doorstatus")).items(): state = value.lower() doors[name] = cast(DoorState, state) if state in DOOR_STATES else "unknown" return doors @@ -112,7 +77,7 @@ async def request_door_status(self) -> Dict[str, DoorState]: async def request_nest_status(self) -> Dict[int, NestState]: """Parsed ``neststatus`` output, keyed by nest number.""" nests: Dict[int, NestState] = {} - for key, value in _parse_kv(await self._driver.send_command("neststatus")).items(): + for key, value in parse_kv(await self._driver.send_command("neststatus")).items(): try: nest = int(key) except ValueError: @@ -331,231 +296,3 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[i async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None): stacker, slot = self._locate(site) await self.place(stacker, slot, self._nest_for_tray(tray_index)) - - -class HighResSampleStorageTemperatureControllerBackend(TemperatureControllerBackend): - """Temperature control for a HighRes sample store (refrigerated, -20 to 4 C).""" - - def __init__(self, driver: "HighResSampleStorageDriver"): - super().__init__() - self._driver = driver - - @property - def supports_active_cooling(self) -> bool: - return True - - async def request_current_temperature(self) -> float: - env = await self._driver.request_environment() - if "TEMP" not in env: - raise HighResSampleStorageError("environmentstatus", ["no TEMP channel reported"]) - return env["TEMP"].current - - async def set_temperature(self, temperature: float): - await self._driver.send_command(f"environmentset TEMP {temperature}") - - async def deactivate(self): - await self._driver.send_command("environment TEMP off") - - -class HighResSampleStorageHumidityControllerBackend(HumidityControllerBackend): - """Humidity monitoring for a HighRes sample store (read-only; no active control).""" - - def __init__(self, driver: "HighResSampleStorageDriver"): - super().__init__() - self._driver = driver - - @property - def supports_humidity_control(self) -> bool: - return False - - async def request_current_humidity(self) -> float: - env = await self._driver.request_environment() - if "RH" not in env: - raise HighResSampleStorageError("environmentstatus", ["no RH channel reported"]) - return env["RH"].current / 100.0 - - async def set_humidity(self, humidity: float): - raise NotImplementedError("HighRes sample stores do not support active humidity control.") - - -class HighResSampleStorageDriver(Driver): - """Transport for HighRes Biosolutions sample stores (TundraStore / SteriStore - / AmbiStore). - - The store exposes a text-based remote-control server over TCP, port 1000. - Commands are case-sensitive, space-separated, terminated with ``\\r\\n``. Each - command is answered with an ``ACK!`` echo, optional data lines, then exactly - one completion line (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the User - Manual, section "Message Formatting". - - :meth:`send_command` is the shared primitive. The driver owns the per-capability - backends (:attr:`automated_retrieval`, :attr:`temperature`, :attr:`humidity`), - which build their commands on top of it. - """ - - @dataclass - class SetupParams(BackendParams): - """Optional parameters for :meth:`setup`.""" - - home_on_setup: bool = False - - def __init__( - self, - host: str, - port: int = 1000, - read_timeout: float = 30.0, - motion_timeout: float = 240.0, - loading_tray_nest: int = 1, - num_nests: int = 2, - ): - """ - Args: - host: IP address of the store. The factory default is ``192.168.127.60``; - all HighRes devices also answer on the backdoor ``10.253.253.253``. - port: Remote-control server port (always 1000). - read_timeout: Timeout (s) for query/status commands. - motion_timeout: Timeout (s) for long-running motion commands - (``home``, ``pick``, ``place``, door moves). - loading_tray_nest: Which nest the :class:`AutomatedRetrieval` capability - uses as its default loading tray (1 or 2). - """ - super().__init__() - self.io = Socket( - human_readable_device_name="HighRes sample store", - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=read_timeout, - ) - self._read_timeout = read_timeout - self._motion_timeout = motion_timeout - self._command_lock = asyncio.Lock() - - self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( - self, loading_tray_nest=loading_tray_nest, num_nests=num_nests - ) - self.temperature = HighResSampleStorageTemperatureControllerBackend(self) - self.humidity = HighResSampleStorageHumidityControllerBackend(self) - - @property - def read_timeout(self) -> float: - return self._read_timeout - - @property - def motion_timeout(self) -> float: - return self._motion_timeout - - def serialize(self) -> dict: - return { - **super().serialize(), - "host": self.io._host, - "port": self.io._port, - "read_timeout": self._read_timeout, - "motion_timeout": self._motion_timeout, - "loading_tray_nest": self.automated_retrieval.loading_tray_nest, - } - - # --- lifecycle ------------------------------------------------------------ - - async def setup(self, backend_params: Optional[BackendParams] = None): - if backend_params is None: - backend_params = HighResSampleStorageDriver.SetupParams() - if not isinstance(backend_params, HighResSampleStorageDriver.SetupParams): - raise TypeError(f"backend_params must be {HighResSampleStorageDriver.SetupParams}") - - await self.io.setup() - version = await self.request_version() - logger.info( - "Connected to %s (serial %s, firmware %s)", - version.product_name, - version.serial_number, - version.firmware_version, - ) - if backend_params.home_on_setup: - await self.automated_retrieval.home() - - async def stop(self): - await self.io.stop() - - # --- transport ------------------------------------------------------------ - - async def _readline(self, timeout: Optional[float]) -> str: - raw = await self.io.readuntil(b"\n", timeout=timeout) - return raw.decode("ascii", errors="replace").rstrip("\r\n") - - async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: - """Send a command and return its data lines (those between the ``ACK!`` echo - and the completion line). - - Raises: - HighResSampleStorageError: if the device replies ``ERROR!``. - HighResSampleStorageAbortedError: if the device replies ``ABORTED!``. - """ - if timeout is None: - timeout = self._read_timeout - async with self._command_lock: - await self.io.write(command.encode("ascii") + b"\r\n") - - data_lines: List[str] = [] - completion: Optional[str] = None - seen_ack = False - while completion is None: - line = await self._readline(timeout) - if line.startswith(ACK_TOKEN) and not seen_ack: - seen_ack = True - continue - if line.startswith(COMPLETION_TOKENS): - completion = line - break - data_lines.append(line) - - if completion.startswith(COMPLETION_ERROR): - # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* - # the ERROR! completion, so they are already collected in data_lines. - error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines - raise HighResSampleStorageError(command, error_lines) - if completion.startswith(COMPLETION_ABORTED): - raise HighResSampleStorageAbortedError(command) - assert completion.startswith(COMPLETION_OK) - return data_lines - - # --- shared device queries ------------------------------------------------ - - async def request_version(self) -> VersionInfo: - raw = _parse_kv(await self.send_command("version")) - return VersionInfo( - product_name=raw.get("Product Name"), - serial_number=raw.get("Serial Number"), - firmware_version=raw.get("Firmware Version"), - firmware_build=raw.get("Firmware Build"), - raw=raw, - ) - - async def request_environment(self) -> Dict[str, EnvironmentParameter]: - """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. - - Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels - (e.g. the gas tank pressures) report only a current value. Shared by the - temperature and humidity capability backends. - """ - out: Dict[str, EnvironmentParameter] = {} - for line in await self.send_command("environmentstatus"): - if ":" not in line: - continue - name, _, rest = line.partition(":") - parts = rest.strip().rstrip(":").split("/") - try: - current = float(parts[0]) - except (ValueError, IndexError): - continue - - def _opt(i: int, parts=parts) -> Optional[float]: - try: - return float(parts[i]) - except (ValueError, IndexError): - return None - - out[name.strip()] = EnvironmentParameter( - name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) - ) - return out diff --git a/pylabrobot/highres/sample_storage/driver/driver.py b/pylabrobot/highres/sample_storage/driver/driver.py new file mode 100644 index 00000000000..c6edd7137ea --- /dev/null +++ b/pylabrobot/highres/sample_storage/driver/driver.py @@ -0,0 +1,207 @@ +import asyncio +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.io.socket import Socket + +from ..errors import HighResSampleStorageAbortedError, HighResSampleStorageError +from ..types import EnvironmentParameter, VersionInfo +from .automated_retrieval import HighResSampleStorageAutomatedRetrievalBackend +from .humidity import HighResSampleStorageHumidityControllerBackend +from .protocol import ( + ACK_TOKEN, + COMPLETION_ABORTED, + COMPLETION_ERROR, + COMPLETION_OK, + COMPLETION_TOKENS, + parse_kv, +) +from .temperature import HighResSampleStorageTemperatureControllerBackend + +logger = logging.getLogger(__name__) + + +class HighResSampleStorageDriver(Driver): + """Transport for HighRes Biosolutions sample stores (TundraStore / SteriStore + / AmbiStore). + + The store exposes a text-based remote-control server over TCP, port 1000. + Commands are case-sensitive, space-separated, terminated with ``\\r\\n``. Each + command is answered with an ``ACK!`` echo, optional data lines, then exactly + one completion line (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the User + Manual, section "Message Formatting". + + :meth:`send_command` is the shared primitive. The driver owns the per-capability + backends (:attr:`automated_retrieval`, :attr:`temperature`, :attr:`humidity`), + which build their commands on top of it. + """ + + @dataclass + class SetupParams(BackendParams): + """Optional parameters for :meth:`setup`.""" + + home_on_setup: bool = False + + def __init__( + self, + host: str, + port: int = 1000, + read_timeout: float = 30.0, + motion_timeout: float = 240.0, + loading_tray_nest: int = 1, + num_nests: int = 2, + ): + """ + Args: + host: IP address of the store. The factory default is ``192.168.127.60``; + all HighRes devices also answer on the backdoor ``10.253.253.253``. + port: Remote-control server port (always 1000). + read_timeout: Timeout (s) for query/status commands. + motion_timeout: Timeout (s) for long-running motion commands + (``home``, ``pick``, ``place``, door moves). + loading_tray_nest: Which nest the :class:`AutomatedRetrieval` capability + uses as its default loading tray (1 or 2). + """ + super().__init__() + self.io = Socket( + human_readable_device_name="HighRes sample store", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=read_timeout, + ) + self._read_timeout = read_timeout + self._motion_timeout = motion_timeout + self._command_lock = asyncio.Lock() + + self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( + self, loading_tray_nest=loading_tray_nest, num_nests=num_nests + ) + self.temperature = HighResSampleStorageTemperatureControllerBackend(self) + self.humidity = HighResSampleStorageHumidityControllerBackend(self) + + @property + def read_timeout(self) -> float: + return self._read_timeout + + @property + def motion_timeout(self) -> float: + return self._motion_timeout + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.io._host, + "port": self.io._port, + "read_timeout": self._read_timeout, + "motion_timeout": self._motion_timeout, + "loading_tray_nest": self.automated_retrieval.loading_tray_nest, + } + + # --- lifecycle ------------------------------------------------------------ + + async def setup(self, backend_params: Optional[BackendParams] = None): + if backend_params is None: + backend_params = HighResSampleStorageDriver.SetupParams() + if not isinstance(backend_params, HighResSampleStorageDriver.SetupParams): + raise TypeError(f"backend_params must be {HighResSampleStorageDriver.SetupParams}") + + await self.io.setup() + version = await self.request_version() + logger.info( + "Connected to %s (serial %s, firmware %s)", + version.product_name, + version.serial_number, + version.firmware_version, + ) + if backend_params.home_on_setup: + await self.automated_retrieval.home() + + async def stop(self): + await self.io.stop() + + # --- transport ------------------------------------------------------------ + + async def _readline(self, timeout: Optional[float]) -> str: + raw = await self.io.readuntil(b"\n", timeout=timeout) + return raw.decode("ascii", errors="replace").rstrip("\r\n") + + async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: + """Send a command and return its data lines (those between the ``ACK!`` echo + and the completion line). + + Raises: + HighResSampleStorageError: if the device replies ``ERROR!``. + HighResSampleStorageAbortedError: if the device replies ``ABORTED!``. + """ + if timeout is None: + timeout = self._read_timeout + async with self._command_lock: + await self.io.write(command.encode("ascii") + b"\r\n") + + data_lines: List[str] = [] + completion: Optional[str] = None + seen_ack = False + while completion is None: + line = await self._readline(timeout) + if line.startswith(ACK_TOKEN) and not seen_ack: + seen_ack = True + continue + if line.startswith(COMPLETION_TOKENS): + completion = line + break + data_lines.append(line) + + if completion.startswith(COMPLETION_ERROR): + # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* + # the ERROR! completion, so they are already collected in data_lines. + error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines + raise HighResSampleStorageError(command, error_lines) + if completion.startswith(COMPLETION_ABORTED): + raise HighResSampleStorageAbortedError(command) + assert completion.startswith(COMPLETION_OK) + return data_lines + + # --- shared device queries ------------------------------------------------ + + async def request_version(self) -> VersionInfo: + raw = parse_kv(await self.send_command("version")) + return VersionInfo( + product_name=raw.get("Product Name"), + serial_number=raw.get("Serial Number"), + firmware_version=raw.get("Firmware Version"), + firmware_build=raw.get("Firmware Build"), + raw=raw, + ) + + async def request_environment(self) -> Dict[str, EnvironmentParameter]: + """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. + + Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels + (e.g. the gas tank pressures) report only a current value. Shared by the + temperature and humidity capability backends. + """ + out: Dict[str, EnvironmentParameter] = {} + for line in await self.send_command("environmentstatus"): + if ":" not in line: + continue + name, _, rest = line.partition(":") + parts = rest.strip().rstrip(":").split("/") + try: + current = float(parts[0]) + except (ValueError, IndexError): + continue + + def _opt(i: int, parts=parts) -> Optional[float]: + try: + return float(parts[i]) + except (ValueError, IndexError): + return None + + out[name.strip()] = EnvironmentParameter( + name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) + ) + return out diff --git a/pylabrobot/highres/sample_storage/driver/humidity.py b/pylabrobot/highres/sample_storage/driver/humidity.py new file mode 100644 index 00000000000..45d176a1f2e --- /dev/null +++ b/pylabrobot/highres/sample_storage/driver/humidity.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend + +from ..errors import HighResSampleStorageError + +if TYPE_CHECKING: + from .driver import HighResSampleStorageDriver + + +class HighResSampleStorageHumidityControllerBackend(HumidityControllerBackend): + """Humidity monitoring for a HighRes sample store (read-only; no active control).""" + + def __init__(self, driver: "HighResSampleStorageDriver"): + super().__init__() + self._driver = driver + + @property + def supports_humidity_control(self) -> bool: + return False + + async def request_current_humidity(self) -> float: + env = await self._driver.request_environment() + if "RH" not in env: + raise HighResSampleStorageError("environmentstatus", ["no RH channel reported"]) + return env["RH"].current / 100.0 + + async def set_humidity(self, humidity: float): + raise NotImplementedError("HighRes sample stores do not support active humidity control.") diff --git a/pylabrobot/highres/sample_storage/driver/protocol.py b/pylabrobot/highres/sample_storage/driver/protocol.py new file mode 100644 index 00000000000..a55bb07e270 --- /dev/null +++ b/pylabrobot/highres/sample_storage/driver/protocol.py @@ -0,0 +1,24 @@ +"""Low-level wire-protocol constants and helpers shared by the driver and the +per-capability backends.""" + +from typing import Dict, List + +# Completion-status tokens that terminate a command's reply (see the manual, +# "Message Formatting"). Every command ends with exactly one of these. +COMPLETION_OK = "OK!" +COMPLETION_ABORTED = "ABORTED!" +COMPLETION_ERROR = "ERROR!" +COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) + +# Immediate command-receipt echo prefix. +ACK_TOKEN = "ACK!" + + +def parse_kv(lines: List[str]) -> Dict[str, str]: + """Parse ``Key: value`` lines into a dict (first colon splits).""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out diff --git a/pylabrobot/highres/sample_storage/driver/temperature.py b/pylabrobot/highres/sample_storage/driver/temperature.py new file mode 100644 index 00000000000..c82b9a6b75f --- /dev/null +++ b/pylabrobot/highres/sample_storage/driver/temperature.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING + +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend + +from ..errors import HighResSampleStorageError + +if TYPE_CHECKING: + from .driver import HighResSampleStorageDriver + + +class HighResSampleStorageTemperatureControllerBackend(TemperatureControllerBackend): + """Temperature control for a HighRes sample store (refrigerated, -20 to 4 C).""" + + def __init__(self, driver: "HighResSampleStorageDriver"): + super().__init__() + self._driver = driver + + @property + def supports_active_cooling(self) -> bool: + return True + + async def request_current_temperature(self) -> float: + env = await self._driver.request_environment() + if "TEMP" not in env: + raise HighResSampleStorageError("environmentstatus", ["no TEMP channel reported"]) + return env["TEMP"].current + + async def set_temperature(self, temperature: float): + await self._driver.send_command(f"environmentset TEMP {temperature}") + + async def deactivate(self): + await self._driver.send_command("environment TEMP off") diff --git a/pylabrobot/highres/sample_storage/sample_storage.py b/pylabrobot/highres/sample_storage/sample_storage.py index df71a90b491..c6e00e2621c 100644 --- a/pylabrobot/highres/sample_storage/sample_storage.py +++ b/pylabrobot/highres/sample_storage/sample_storage.py @@ -14,7 +14,7 @@ ) from pylabrobot.resources.resource import Resource -from .backend import HighResSampleStorageDriver +from .driver import HighResSampleStorageDriver class _HighResSampleStorage(Resource, Device): From 88dcfc95ac3fa4a4aca117eb6c5459a6d5ce207d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:40:24 -0700 Subject: [PATCH 20/23] sample_storage: move tests into a tests/ package, split by concern Relocate backend_tests.py into tests/, matching the repo convention of a tests/ package with *_tests.py modules, and split the two suites (which share no fixtures) into self-contained files: - tests/driver_tests.py - transport, queries, temperature/humidity (FakeSocket) - tests/recovery_tests.py - pick/place fault classification + recovery (ScriptedSocket) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../highres/sample_storage/tests/__init__.py | 0 .../sample_storage/tests/driver_tests.py | 175 ++++++++++++++++++ .../recovery_tests.py} | 174 +---------------- 3 files changed, 177 insertions(+), 172 deletions(-) create mode 100644 pylabrobot/highres/sample_storage/tests/__init__.py create mode 100644 pylabrobot/highres/sample_storage/tests/driver_tests.py rename pylabrobot/highres/sample_storage/{backend_tests.py => tests/recovery_tests.py} (50%) diff --git a/pylabrobot/highres/sample_storage/tests/__init__.py b/pylabrobot/highres/sample_storage/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/highres/sample_storage/tests/driver_tests.py b/pylabrobot/highres/sample_storage/tests/driver_tests.py new file mode 100644 index 00000000000..97ead3d64b6 --- /dev/null +++ b/pylabrobot/highres/sample_storage/tests/driver_tests.py @@ -0,0 +1,175 @@ +import unittest +from typing import Dict, List + +from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver +from pylabrobot.highres.sample_storage.errors import HighResSampleStorageError + +# Real responses captured from a TundraStore (firmware 3.0.0.119, serial +# HRB-2209-35148) over the port-1000 remote-control server. +CAPTURES: Dict[str, List[str]] = { + "version": [ + "ACK! version 1", + "Product Name: SteriStore", + "Serial Number: HRB-2209-35148", + "libcommon Version: 1.1.0.119", + "libts7600 Version: 1.0.0.119", + "Firmware Version: 3.0.0.119", + "Firmware Build: D9BE232A", + "OK! version 1", + ], + "homedstatus": ["ACK! homedstatus 5", "not homed", "OK! homedstatus 5"], + "doorstatus": [ + "ACK! doorstatus 17", + "User Door: CLOSED", + "RI: CLOSED", + "SEAL: CLOSING", + "RO1: CLOSING", + "RO2: CLOSED", + "RO3: CLOSED", + "RO4: CLOSED", + "OK! doorstatus 17", + ], + "platestatus": ["ACK! platestatus 9", "NO_PLATE", "OK! platestatus 9"], + "neststatus": ["ACK! neststatus 11", "1: CLEAR", "2: CLEAR", "OK! neststatus 11"], + "environmentstatus": [ + "ACK! environmentstatus 31", + "TEMP:21.9/22.0/100.0", + "RH:54.7/0.0/-100.0", + "CO2:0.0/5.0/100.0", + "O2:20.5/5.0/100.0", + "TANK1:135.0:", + "TANK2:135.0:", + "OK! environmentstatus 31", + ], + "getstackerdimensions": [ + "ACK! getstackerdimensions 35", + "1: 0.000 28.940 0", + "2: 0.000 22.867 24", + "13: 0.000 22.867 0", + "OK! getstackerdimensions 35", + ], + # The home command failed because the pneumatic doors could not close (no air). + "home": [ + "ACK! home 13", + "Error 1: (00:32:44) 13: Unable to close all doors", + "ERROR! home 13", + ], +} + + +class FakeSocket: + """Replays scripted line responses keyed by the command written to it.""" + + def __init__(self, captures: Dict[str, List[str]]): + self.captures = captures + self.written: List[str] = [] + self._queue: List[str] = [] + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes, timeout=None): + command = data.decode("ascii").rstrip("\r\n") + self.written.append(command) + self._queue = list(self.captures[command]) + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + return self._queue.pop(0).encode("ascii") + b"\r\n" + + +class HighResSampleStorageBackendTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.driver = HighResSampleStorageDriver(host="10.253.253.253") + self.socket = FakeSocket(CAPTURES) + self.driver.io = self.socket # type: ignore[assignment] + self.retrieval = self.driver.automated_retrieval + + async def test_send_command_strips_ack_and_completion(self): + data = await self.driver.send_command("neststatus") + self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) + self.assertEqual(self.socket.written, ["neststatus"]) + + async def test_version(self): + v = await self.driver.request_version() + self.assertEqual(v.product_name, "SteriStore") + self.assertEqual(v.serial_number, "HRB-2209-35148") + self.assertEqual(v.firmware_version, "3.0.0.119") + self.assertEqual(v.firmware_build, "D9BE232A") + + async def test_is_homed(self): + self.assertFalse(await self.retrieval.is_homed()) + + async def test_door_status(self): + doors = await self.retrieval.request_door_status() + self.assertEqual(doors["User Door"], "closed") + self.assertEqual(doors["SEAL"], "closing") + self.assertEqual(doors["RO1"], "closing") + self.assertFalse(all(state == "closed" for state in doors.values())) + + async def test_nest_status(self): + nests = await self.retrieval.request_nest_status() + self.assertEqual(nests, {1: "clear", 2: "clear"}) + + async def test_plate_on_spatula(self): + self.assertFalse(await self.retrieval.spatula_request_is_holding()) + + async def test_environment_parsing(self): + env = await self.driver.request_environment() + self.assertAlmostEqual(env["TEMP"].current, 21.9) + self.assertIsNotNone(env["TEMP"].setpoint) + assert env["TEMP"].setpoint is not None # narrow for type checker + self.assertAlmostEqual(env["TEMP"].setpoint, 22.0) + self.assertAlmostEqual(env["O2"].current, 20.5) + # Sensor-only channel: current value, no setpoint. + self.assertAlmostEqual(env["TANK1"].current, 135.0) + self.assertIsNone(env["TANK1"].setpoint) + + async def test_temperature_capability_reads_temp_channel(self): + self.assertAlmostEqual(await self.driver.temperature.request_current_temperature(), 21.9) + self.assertTrue(self.driver.temperature.supports_active_cooling) + + async def test_humidity_capability_reads_rh_as_fraction(self): + self.assertAlmostEqual(await self.driver.humidity.request_current_humidity(), 0.547) + self.assertFalse(self.driver.humidity.supports_humidity_control) + + async def test_stacker_dimensions(self): + dims = await self.retrieval.get_stacker_dimensions() + self.assertEqual(dims[0].stacker, 1) + self.assertEqual(dims[0].slot_count, 0) + self.assertEqual(dims[1].stacker, 2) + self.assertAlmostEqual(dims[1].slot_height, 22.867) + self.assertEqual(dims[1].slot_count, 24) + + async def test_home_error_raises_with_stack_detail(self): + with self.assertRaises(HighResSampleStorageError) as ctx: + await self.retrieval.home() + self.assertIn("Unable to close all doors", str(ctx.exception)) + self.assertEqual(ctx.exception.command, "home") + + async def test_pick_formats_command(self): + self.socket.captures["pick 3 12 1"] = ["ACK! pick 3 12 1 99", "OK! pick 3 12 1 99"] + await self.retrieval.pick(3, 12, 1) + self.assertEqual(self.socket.written, ["pick 3 12 1"]) + + def test_tray_maps_to_nest(self): + # 0-based capability tray -> 1-based device nest; None uses the default. + self.assertEqual(self.retrieval._nest_for_tray(None), 1) + self.assertEqual(self.retrieval._nest_for_tray(0), 1) + self.assertEqual(self.retrieval._nest_for_tray(1), 2) + with self.assertRaises(ValueError): + self.retrieval._nest_for_tray(2) + + def test_default_tray_follows_loading_tray_nest(self): + driver = HighResSampleStorageDriver(host="10.253.253.253", loading_tray_nest=2) + self.assertEqual(driver.automated_retrieval._nest_for_tray(None), 2) + + async def test_set_humidity_unsupported(self): + with self.assertRaises(NotImplementedError): + await self.driver.humidity.set_humidity(0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/highres/sample_storage/backend_tests.py b/pylabrobot/highres/sample_storage/tests/recovery_tests.py similarity index 50% rename from pylabrobot/highres/sample_storage/backend_tests.py rename to pylabrobot/highres/sample_storage/tests/recovery_tests.py index f72978978e4..a41510e00dd 100644 --- a/pylabrobot/highres/sample_storage/backend_tests.py +++ b/pylabrobot/highres/sample_storage/tests/recovery_tests.py @@ -1,178 +1,8 @@ import unittest -from typing import Dict, List +from typing import List from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver -from pylabrobot.highres.sample_storage.errors import ( - PlateNotFoundError, - HighResSampleStorageError, - HighResSampleStorageFault, -) - -# Real responses captured from a TundraStore (firmware 3.0.0.119, serial -# HRB-2209-35148) over the port-1000 remote-control server. -CAPTURES: Dict[str, List[str]] = { - "version": [ - "ACK! version 1", - "Product Name: SteriStore", - "Serial Number: HRB-2209-35148", - "libcommon Version: 1.1.0.119", - "libts7600 Version: 1.0.0.119", - "Firmware Version: 3.0.0.119", - "Firmware Build: D9BE232A", - "OK! version 1", - ], - "homedstatus": ["ACK! homedstatus 5", "not homed", "OK! homedstatus 5"], - "doorstatus": [ - "ACK! doorstatus 17", - "User Door: CLOSED", - "RI: CLOSED", - "SEAL: CLOSING", - "RO1: CLOSING", - "RO2: CLOSED", - "RO3: CLOSED", - "RO4: CLOSED", - "OK! doorstatus 17", - ], - "platestatus": ["ACK! platestatus 9", "NO_PLATE", "OK! platestatus 9"], - "neststatus": ["ACK! neststatus 11", "1: CLEAR", "2: CLEAR", "OK! neststatus 11"], - "environmentstatus": [ - "ACK! environmentstatus 31", - "TEMP:21.9/22.0/100.0", - "RH:54.7/0.0/-100.0", - "CO2:0.0/5.0/100.0", - "O2:20.5/5.0/100.0", - "TANK1:135.0:", - "TANK2:135.0:", - "OK! environmentstatus 31", - ], - "getstackerdimensions": [ - "ACK! getstackerdimensions 35", - "1: 0.000 28.940 0", - "2: 0.000 22.867 24", - "13: 0.000 22.867 0", - "OK! getstackerdimensions 35", - ], - # The home command failed because the pneumatic doors could not close (no air). - "home": [ - "ACK! home 13", - "Error 1: (00:32:44) 13: Unable to close all doors", - "ERROR! home 13", - ], -} - - -class FakeSocket: - """Replays scripted line responses keyed by the command written to it.""" - - def __init__(self, captures: Dict[str, List[str]]): - self.captures = captures - self.written: List[str] = [] - self._queue: List[str] = [] - - async def setup(self): - pass - - async def stop(self): - pass - - async def write(self, data: bytes, timeout=None): - command = data.decode("ascii").rstrip("\r\n") - self.written.append(command) - self._queue = list(self.captures[command]) - - async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: - return self._queue.pop(0).encode("ascii") + b"\r\n" - - -class HighResSampleStorageBackendTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.driver = HighResSampleStorageDriver(host="10.253.253.253") - self.socket = FakeSocket(CAPTURES) - self.driver.io = self.socket # type: ignore[assignment] - self.retrieval = self.driver.automated_retrieval - - async def test_send_command_strips_ack_and_completion(self): - data = await self.driver.send_command("neststatus") - self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) - self.assertEqual(self.socket.written, ["neststatus"]) - - async def test_version(self): - v = await self.driver.request_version() - self.assertEqual(v.product_name, "SteriStore") - self.assertEqual(v.serial_number, "HRB-2209-35148") - self.assertEqual(v.firmware_version, "3.0.0.119") - self.assertEqual(v.firmware_build, "D9BE232A") - - async def test_is_homed(self): - self.assertFalse(await self.retrieval.is_homed()) - - async def test_door_status(self): - doors = await self.retrieval.request_door_status() - self.assertEqual(doors["User Door"], "closed") - self.assertEqual(doors["SEAL"], "closing") - self.assertEqual(doors["RO1"], "closing") - self.assertFalse(all(state == "closed" for state in doors.values())) - - async def test_nest_status(self): - nests = await self.retrieval.request_nest_status() - self.assertEqual(nests, {1: "clear", 2: "clear"}) - - async def test_plate_on_spatula(self): - self.assertFalse(await self.retrieval.spatula_request_is_holding()) - - async def test_environment_parsing(self): - env = await self.driver.request_environment() - self.assertAlmostEqual(env["TEMP"].current, 21.9) - self.assertIsNotNone(env["TEMP"].setpoint) - assert env["TEMP"].setpoint is not None # narrow for type checker - self.assertAlmostEqual(env["TEMP"].setpoint, 22.0) - self.assertAlmostEqual(env["O2"].current, 20.5) - # Sensor-only channel: current value, no setpoint. - self.assertAlmostEqual(env["TANK1"].current, 135.0) - self.assertIsNone(env["TANK1"].setpoint) - - async def test_temperature_capability_reads_temp_channel(self): - self.assertAlmostEqual(await self.driver.temperature.request_current_temperature(), 21.9) - self.assertTrue(self.driver.temperature.supports_active_cooling) - - async def test_humidity_capability_reads_rh_as_fraction(self): - self.assertAlmostEqual(await self.driver.humidity.request_current_humidity(), 0.547) - self.assertFalse(self.driver.humidity.supports_humidity_control) - - async def test_stacker_dimensions(self): - dims = await self.retrieval.get_stacker_dimensions() - self.assertEqual(dims[0].stacker, 1) - self.assertEqual(dims[0].slot_count, 0) - self.assertEqual(dims[1].stacker, 2) - self.assertAlmostEqual(dims[1].slot_height, 22.867) - self.assertEqual(dims[1].slot_count, 24) - - async def test_home_error_raises_with_stack_detail(self): - with self.assertRaises(HighResSampleStorageError) as ctx: - await self.retrieval.home() - self.assertIn("Unable to close all doors", str(ctx.exception)) - self.assertEqual(ctx.exception.command, "home") - - async def test_pick_formats_command(self): - self.socket.captures["pick 3 12 1"] = ["ACK! pick 3 12 1 99", "OK! pick 3 12 1 99"] - await self.retrieval.pick(3, 12, 1) - self.assertEqual(self.socket.written, ["pick 3 12 1"]) - - def test_tray_maps_to_nest(self): - # 0-based capability tray -> 1-based device nest; None uses the default. - self.assertEqual(self.retrieval._nest_for_tray(None), 1) - self.assertEqual(self.retrieval._nest_for_tray(0), 1) - self.assertEqual(self.retrieval._nest_for_tray(1), 2) - with self.assertRaises(ValueError): - self.retrieval._nest_for_tray(2) - - def test_default_tray_follows_loading_tray_nest(self): - driver = HighResSampleStorageDriver(host="10.253.253.253", loading_tray_nest=2) - self.assertEqual(driver.automated_retrieval._nest_for_tray(None), 2) - - async def test_set_humidity_unsupported(self): - with self.assertRaises(NotImplementedError): - await self.driver.humidity.set_humidity(0.5) +from pylabrobot.highres.sample_storage.errors import HighResSampleStorageFault, PlateNotFoundError def _ok(command: str, cid: int) -> List[str]: From bf0663e7a3ca4f43d4c7a5df784f9f8146879053 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:45:54 -0700 Subject: [PATCH 21/23] sample_storage: prefix device-read methods with request_ Use a consistent request_ prefix for every method that reads state from the device, matching request_version/request_environment/etc: - get_stacker_dimensions -> request_stacker_dimensions - scan_stacker_barcodes -> request_stacker_barcodes - is_homed -> request_is_homed - is_parked -> request_is_parked - spatula_request_is_holding -> request_spatula_is_holding - nest_request_is_holding -> request_nest_is_holding Co-Authored-By: Claude Opus 4.8 (1M context) --- .../driver/automated_retrieval.py | 28 +++++++++---------- .../sample_storage/tests/driver_tests.py | 8 +++--- .../sample_storage/tests/recovery_tests.py | 4 +-- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pylabrobot/highres/sample_storage/driver/automated_retrieval.py b/pylabrobot/highres/sample_storage/driver/automated_retrieval.py index a67b5dfde10..3882a5d06c0 100644 --- a/pylabrobot/highres/sample_storage/driver/automated_retrieval.py +++ b/pylabrobot/highres/sample_storage/driver/automated_retrieval.py @@ -45,7 +45,7 @@ def __init__( self.loading_tray_nest = loading_tray_nest self.num_nests = num_nests # Slide (Y) below this is "retracted"; a spatula stuck in a stacker sits at - # the ~256mm slide-in depth, home is 0. Used by is_parked()/recover(). + # the ~256mm slide-in depth, home is 0. Used by request_is_parked()/recover(). self._retracted_y_max = 50.0 # stacker/slot lookup, built from racks by set_racks(). self._site_locations: Dict[str, Tuple[int, int]] = {} @@ -62,7 +62,7 @@ async def request_axis_positions(self) -> Dict[str, float]: continue return out - async def is_homed(self) -> bool: + async def request_is_homed(self) -> bool: lines = await self._driver.send_command("homedstatus") return any(line.strip().lower() == "homed" for line in lines) @@ -86,12 +86,12 @@ async def request_nest_status(self) -> Dict[int, NestState]: nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" return nests - async def spatula_request_is_holding(self) -> bool: + async def request_spatula_is_holding(self) -> bool: """Whether a plate is currently held on the spatula (``platestatus``).""" lines = await self._driver.send_command("platestatus") return not any("NO_PLATE" in line for line in lines) - async def nest_request_is_holding(self, nest: int) -> bool: + async def request_nest_is_holding(self, nest: int) -> bool: """Whether a plate is present on ``nest`` (per its plate sensor). Note: this unit reports an occupied nest as ``UNKNOWN`` rather than @@ -107,7 +107,7 @@ async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> boo SIDE EFFECT: a plate that is found is moved to ``to_nest`` (the only way to sense a stacker slot is to pick it). Only safe for non-top slots, where an empty pick is graceful; the top slot (24) faults when empty — see - :meth:`pick`. For nests, use :meth:`nest_request_is_holding` instead (a + :meth:`pick`. For nests, use :meth:`request_nest_is_holding` instead (a non-destructive sensor read). """ try: @@ -116,7 +116,7 @@ async def probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> boo except PlateNotFoundError: return False - async def get_stacker_dimensions(self) -> List[StackerDimensions]: + async def request_stacker_dimensions(self) -> List[StackerDimensions]: """Parse ``getstackerdimensions`` (``: ``).""" dims: List[StackerDimensions] = [] @@ -143,7 +143,7 @@ async def request_settings(self) -> HighResSampleStorageSettings: lines = await self._driver.send_command("settings", timeout=self._driver.read_timeout) return HighResSampleStorageSettings.from_lines(lines) - async def scan_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: + async def request_stacker_barcodes(self, stacker, slot: Optional[int] = None) -> List[str]: """Scan a stacker (or a single slot) for barcodes. Args: @@ -164,16 +164,16 @@ async def home(self): :class:`HighResSampleStorageError` ("Unable to close all doors").""" await self._driver.send_command("home", timeout=self._driver.motion_timeout) - async def is_parked(self) -> bool: + async def request_is_parked(self) -> bool: """Whether the machine is genuinely safe to move: homed AND the spatula retracted out of the carousel. - Prefer this over :meth:`is_homed`. ``homedstatus`` reports homed even while + Prefer this over :meth:`request_is_homed`. ``homedstatus`` reports homed even while the spatula is stuck extended in a stacker after a faulted top-slot pick, so it alone is not a safe-state check; this also verifies the slide (Y) axis is near its home position (a stuck spatula sits at the ~256mm slide-in depth). """ - if not await self.is_homed(): + if not await self.request_is_homed(): return False y = (await self.request_axis_positions()).get("Y axis") return y is not None and abs(y) < self._retracted_y_max @@ -185,7 +185,7 @@ async def recover(self) -> bool: leave the spatula extended. This ALWAYS issues the retract (``spatulaout``) + ``home`` — it does not trust ``homedstatus`` to decide whether recovery is needed, because that reports homed even while the spatula is stuck extended. - Retries a few times. Returns ``True`` once :meth:`is_parked`. + Retries a few times. Returns ``True`` once :meth:`request_is_parked`. """ for _ in range(3): for command in ("enable", "spatulaout"): @@ -197,7 +197,7 @@ async def recover(self) -> bool: await self._driver.send_command("home", timeout=self._driver.motion_timeout) except HighResSampleStorageError: pass - if await self.is_parked(): + if await self.request_is_parked(): return True return False @@ -216,13 +216,13 @@ async def pick(self, stacker: int, slot: int, nest: int, close_door: bool = True Note: ``homedstatus`` reports homed even when the spatula is stuck extended at a top slot, so the firmware's own "unsafe for rotation" signal is used - (not just :meth:`is_homed`) to detect that case. + (not just :meth:`request_is_homed`) to detect that case. """ command = f"pick {stacker} {slot} {nest}" try: await self._driver.send_command(command, timeout=self._driver.motion_timeout) except HighResSampleStorageError as exc: - if left_unsafe(exc.error_lines) or not await self.is_homed(): + if left_unsafe(exc.error_lines) or not await self.request_is_homed(): raise HighResSampleStorageFault(command, exc.error_lines) from exc if any("no plate detected" in line.lower() for line in exc.error_lines): raise PlateNotFoundError(command, exc.error_lines) from exc diff --git a/pylabrobot/highres/sample_storage/tests/driver_tests.py b/pylabrobot/highres/sample_storage/tests/driver_tests.py index 97ead3d64b6..f4fc583e816 100644 --- a/pylabrobot/highres/sample_storage/tests/driver_tests.py +++ b/pylabrobot/highres/sample_storage/tests/driver_tests.py @@ -99,8 +99,8 @@ async def test_version(self): self.assertEqual(v.firmware_version, "3.0.0.119") self.assertEqual(v.firmware_build, "D9BE232A") - async def test_is_homed(self): - self.assertFalse(await self.retrieval.is_homed()) + async def test_request_is_homed(self): + self.assertFalse(await self.retrieval.request_is_homed()) async def test_door_status(self): doors = await self.retrieval.request_door_status() @@ -114,7 +114,7 @@ async def test_nest_status(self): self.assertEqual(nests, {1: "clear", 2: "clear"}) async def test_plate_on_spatula(self): - self.assertFalse(await self.retrieval.spatula_request_is_holding()) + self.assertFalse(await self.retrieval.request_spatula_is_holding()) async def test_environment_parsing(self): env = await self.driver.request_environment() @@ -136,7 +136,7 @@ async def test_humidity_capability_reads_rh_as_fraction(self): self.assertFalse(self.driver.humidity.supports_humidity_control) async def test_stacker_dimensions(self): - dims = await self.retrieval.get_stacker_dimensions() + dims = await self.retrieval.request_stacker_dimensions() self.assertEqual(dims[0].stacker, 1) self.assertEqual(dims[0].slot_count, 0) self.assertEqual(dims[1].stacker, 2) diff --git a/pylabrobot/highres/sample_storage/tests/recovery_tests.py b/pylabrobot/highres/sample_storage/tests/recovery_tests.py index a41510e00dd..b1b6698fde8 100644 --- a/pylabrobot/highres/sample_storage/tests/recovery_tests.py +++ b/pylabrobot/highres/sample_storage/tests/recovery_tests.py @@ -108,11 +108,11 @@ def _status(self, cid: int, y: float) -> List[str]: f"OK! status {cid}", ] - async def test_is_parked_catches_the_homed_lie(self): + async def test_request_is_parked_catches_the_homed_lie(self): # homedstatus says homed, but the spatula is stuck extended (Y=256) -> NOT parked. sock = ScriptedSocket([("homedstatus", self._homed(1)), ("status", self._status(2, 255.9999))]) self.driver.io = sock # type: ignore[assignment] - self.assertFalse(await self.retrieval.is_parked()) + self.assertFalse(await self.retrieval.request_is_parked()) async def test_recover_always_retracts_and_rehomes(self): # recover() must issue retract+home even when homedstatus already says homed From f31a19f490021f91897696c267e0abb469d98517 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 16:59:51 -0700 Subject: [PATCH 22/23] sample_storage: chatterbox builds on the real driver, like STAR's chatterbox Mirror STARChatterboxDriver: call the real driver __init__ (which constructs an inert, unconnected socket and owns the real per-capability backends) and only override setup/send_command/request_* to fake the transport. Drops the `self.io = None # type: ignore` placeholder and the duplicate backend construction that came from bypassing the parent constructor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../highres/sample_storage/chatterbox.py | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/pylabrobot/highres/sample_storage/chatterbox.py b/pylabrobot/highres/sample_storage/chatterbox.py index d6bb82d7c29..a26ee4dc9b4 100644 --- a/pylabrobot/highres/sample_storage/chatterbox.py +++ b/pylabrobot/highres/sample_storage/chatterbox.py @@ -2,14 +2,8 @@ from typing import Dict, List, Optional from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.device import Driver -from .driver import ( - HighResSampleStorageAutomatedRetrievalBackend, - HighResSampleStorageDriver, - HighResSampleStorageHumidityControllerBackend, - HighResSampleStorageTemperatureControllerBackend, -) +from .driver import HighResSampleStorageDriver from .types import EnvironmentParameter, VersionInfo logger = logging.getLogger(__name__) @@ -18,9 +12,11 @@ class HighResSampleStorageChatterboxDriver(HighResSampleStorageDriver): """Device-free driver that logs commands instead of talking to hardware. - Owns the real per-capability backends, so it exercises their command-building - logic; only the transport (:meth:`send_command`) and the shared device queries - are faked. Useful for testing protocols and resource assignment offline. + Builds on the real driver, so it owns the real per-capability backends and + exercises their command-building logic; only the transport (:meth:`send_command`) + and the shared device queries are faked. The socket is constructed (inert) but + never connected, because :meth:`setup` is overridden to skip the connect. + Useful for testing protocols and resource assignment offline. """ def __init__( @@ -30,19 +26,10 @@ def __init__( loading_tray_nest: int = 1, num_nests: int = 2, ): - Driver.__init__(self) - self.io = None # type: ignore[assignment] - self._read_timeout = 30.0 - self._motion_timeout = 240.0 + super().__init__(host="chatterbox", loading_tray_nest=loading_tray_nest, num_nests=num_nests) self._temperature = temperature self._humidity = humidity - self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( - self, loading_tray_nest=loading_tray_nest, num_nests=num_nests - ) - self.temperature = HighResSampleStorageTemperatureControllerBackend(self) - self.humidity = HighResSampleStorageHumidityControllerBackend(self) - async def setup(self, backend_params: Optional[BackendParams] = None): logger.info("[chatterbox] setup") From cd30374a8a7a43c063a036bd9c6d0b1d9af0f193 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 28 Jun 2026 17:02:57 -0700 Subject: [PATCH 23/23] sample_storage: drop the chatterbox driver for now Remove the device-free chatterbox driver and its export; it can be reintroduced later if needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/highres/sample_storage/__init__.py | 1 - .../highres/sample_storage/chatterbox.py | 56 ------------------- 2 files changed, 57 deletions(-) delete mode 100644 pylabrobot/highres/sample_storage/chatterbox.py diff --git a/pylabrobot/highres/sample_storage/__init__.py b/pylabrobot/highres/sample_storage/__init__.py index 2aaaf28bdc9..17997bf53da 100644 --- a/pylabrobot/highres/sample_storage/__init__.py +++ b/pylabrobot/highres/sample_storage/__init__.py @@ -4,7 +4,6 @@ HighResSampleStorageHumidityControllerBackend, HighResSampleStorageTemperatureControllerBackend, ) -from .chatterbox import HighResSampleStorageChatterboxDriver from .errors import ( PlateNotFoundError, HighResSampleStorageAbortedError, diff --git a/pylabrobot/highres/sample_storage/chatterbox.py b/pylabrobot/highres/sample_storage/chatterbox.py deleted file mode 100644 index a26ee4dc9b4..00000000000 --- a/pylabrobot/highres/sample_storage/chatterbox.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging -from typing import Dict, List, Optional - -from pylabrobot.capabilities.capability import BackendParams - -from .driver import HighResSampleStorageDriver -from .types import EnvironmentParameter, VersionInfo - -logger = logging.getLogger(__name__) - - -class HighResSampleStorageChatterboxDriver(HighResSampleStorageDriver): - """Device-free driver that logs commands instead of talking to hardware. - - Builds on the real driver, so it owns the real per-capability backends and - exercises their command-building logic; only the transport (:meth:`send_command`) - and the shared device queries are faked. The socket is constructed (inert) but - never connected, because :meth:`setup` is overridden to skip the connect. - Useful for testing protocols and resource assignment offline. - """ - - def __init__( - self, - temperature: float = 4.0, - humidity: float = 0.5, - loading_tray_nest: int = 1, - num_nests: int = 2, - ): - super().__init__(host="chatterbox", loading_tray_nest=loading_tray_nest, num_nests=num_nests) - self._temperature = temperature - self._humidity = humidity - - async def setup(self, backend_params: Optional[BackendParams] = None): - logger.info("[chatterbox] setup") - - async def stop(self): - logger.info("[chatterbox] stop") - - async def send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: - logger.info("[chatterbox] %s", command) - return [] - - async def request_version(self) -> VersionInfo: - return VersionInfo( - product_name="HighRes sample store", - serial_number="CHATTERBOX", - firmware_version="0.0.0", - firmware_build=None, - raw={}, - ) - - async def request_environment(self) -> Dict[str, EnvironmentParameter]: - return { - "TEMP": EnvironmentParameter(name="TEMP", current=self._temperature), - "RH": EnvironmentParameter(name="RH", current=self._humidity * 100.0), - }