From 3c7698ec8d298045d00c366ed23f8bf006dbbd4e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 20 Aug 2026 16:06:21 -0700 Subject: [PATCH 1/8] feat(high_res): add TundraStore and SteriStore drivers --- .../high_res/sample_storage/__init__.py | 23 + .../sample_storage/driver/__init__.py | 4 + .../driver/automated_retrieval.py | 303 ++++++++ .../high_res/sample_storage/driver/driver.py | 205 ++++++ .../sample_storage/driver/humidity.py | 29 + .../sample_storage/driver/protocol.py | 24 + .../sample_storage/driver/temperature.py | 32 + pylabrobot/high_res/sample_storage/errors.py | 70 ++ .../high_res/sample_storage/sample_storage.py | 141 ++++ .../high_res/sample_storage/settings.py | 697 ++++++++++++++++++ .../high_res/sample_storage/tests/__init__.py | 0 .../sample_storage/tests/driver_tests.py | 177 +++++ .../sample_storage/tests/recovery_tests.py | 182 +++++ pylabrobot/high_res/sample_storage/types.py | 51 ++ 14 files changed, 1938 insertions(+) create mode 100644 pylabrobot/high_res/sample_storage/__init__.py create mode 100644 pylabrobot/high_res/sample_storage/driver/__init__.py create mode 100644 pylabrobot/high_res/sample_storage/driver/automated_retrieval.py create mode 100644 pylabrobot/high_res/sample_storage/driver/driver.py create mode 100644 pylabrobot/high_res/sample_storage/driver/humidity.py create mode 100644 pylabrobot/high_res/sample_storage/driver/protocol.py create mode 100644 pylabrobot/high_res/sample_storage/driver/temperature.py create mode 100644 pylabrobot/high_res/sample_storage/errors.py create mode 100644 pylabrobot/high_res/sample_storage/sample_storage.py create mode 100644 pylabrobot/high_res/sample_storage/settings.py create mode 100644 pylabrobot/high_res/sample_storage/tests/__init__.py create mode 100644 pylabrobot/high_res/sample_storage/tests/driver_tests.py create mode 100644 pylabrobot/high_res/sample_storage/tests/recovery_tests.py create mode 100644 pylabrobot/high_res/sample_storage/types.py diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py new file mode 100644 index 00000000000..17997bf53da --- /dev/null +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -0,0 +1,23 @@ +from .driver import ( + HighResSampleStorageAutomatedRetrievalBackend, + HighResSampleStorageDriver, + HighResSampleStorageHumidityControllerBackend, + HighResSampleStorageTemperatureControllerBackend, +) +from .errors import ( + PlateNotFoundError, + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, +) +from .settings import MachineType, HighResSampleStorageSettings +from .types import ( + DoorState, + EnvironmentParameter, + NestState, + StackerDimensions, + VersionInfo, +) +from pylabrobot.capabilities.automated_retrieval import NoFreeSiteError + +from .sample_storage import AmbiStore, SteriStore, TundraStore diff --git a/pylabrobot/high_res/sample_storage/driver/__init__.py b/pylabrobot/high_res/sample_storage/driver/__init__.py new file mode 100644 index 00000000000..a262b404768 --- /dev/null +++ b/pylabrobot/high_res/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/high_res/sample_storage/driver/automated_retrieval.py b/pylabrobot/high_res/sample_storage/driver/automated_retrieval.py new file mode 100644 index 00000000000..45fd2aefd63 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/automated_retrieval.py @@ -0,0 +1,303 @@ +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, cast + +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder + +from ..errors import ( + HighResSampleStorageError, + HighResSampleStorageFault, + PlateNotFoundError, + left_unsafe, +) +from ..settings import HighResSampleStorageSettings +from ..types import DOOR_STATES, NEST_STATES, DoorState, NestState, StackerDimensions +from .protocol import parse_kv + +if TYPE_CHECKING: + from .driver import HighResSampleStorageDriver + + +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 + *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. + + The store has two nests, exposed through the multi-tray + :class:`AutomatedRetrieval` capability: ``tray_index`` is a 0-based tray index, + and ``tray_index=None`` selects :attr:`default_tray_index`. The 0-based index is + converted to the device's 1-based nest number internally (:meth:`_nest_for_tray`). + + All commands are issued through the owning :class:`HighResSampleStorageDriver`. + """ + + def __init__( + self, + driver: "HighResSampleStorageDriver", + default_tray_index: int = 0, + num_nests: int = 2, + ): + super().__init__() + self._driver = driver + self._default_tray_index = default_tray_index + 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 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]] = {} + + # --- queries (verified against firmware 3.0.0.119) ------------------------ + + 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(): + try: + out[key] = float(value) + except ValueError: + continue + return out + + async def request_is_homed(self) -> bool: + 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 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 + + 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(): + try: + nest = int(key) + except ValueError: + continue + state = value.lower() + nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" + return nests + + 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 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 + ``OCCUPIED``; anything other than ``CLEAR`` counts as holding. + """ + state = (await self.request_nest_status()).get(nest) + 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 + 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:`request_nest_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_stacker_dimensions(self) -> List[StackerDimensions]: + """Parse ``getstackerdimensions`` (``: + ``).""" + dims: List[StackerDimensions] = [] + for line in await self._driver.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 request_settings(self) -> HighResSampleStorageSettings: + """Read the device's full settings file (``NAME = value`` pairs) into a + frozen :class:`HighResSampleStorageSettings`.""" + lines = await self._driver.send_command("settings", timeout=self._driver.read_timeout) + return HighResSampleStorageSettings.from_lines(lines) + + async def request_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._driver.send_command(command, timeout=self._driver.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:`HighResSampleStorageError` ("Unable to close all doors").""" + await self._driver.send_command("home", timeout=self._driver.motion_timeout) + + 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:`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.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 + + async def recover(self) -> bool: + """Retract the spatula and re-home after a motion fault. + + 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:`request_is_parked`. + """ + for _ in range(3): + for command in ("enable", "spatulaout"): + try: + await self._driver.send_command(command, timeout=self._driver.motion_timeout) + except HighResSampleStorageError: + pass + try: + await self._driver.send_command("home", timeout=self._driver.motion_timeout) + except HighResSampleStorageError: + pass + if await self.request_is_parked(): + return True + return False + + 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") + and the store retracted cleanly; the machine is safe to keep using. + - :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. + + 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:`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.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 + 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)``. + + 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._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._driver.send_command("openalldoors", timeout=self._driver.motion_timeout) + + async def close_all_doors(self): + 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._driver.send_command("abort") + + async def clear_abort(self): + await self._driver.send_command("clearabort") + + # --- AutomatedRetrieval capability ---------------------------------------- + + 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``.""" + 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] + + @property + def default_tray_index(self) -> int: + """0-based tray index used when ``tray_index`` is ``None`` (see the base backend).""" + return self._default_tray_index + + def _nest_for_tray(self, tray_index: Optional[int]) -> int: + """Map a 0-based capability tray index to the device's 1-based nest number. + + ``None`` selects :attr:`default_tray_index` (the configured default tray).""" + if tray_index is None: + tray_index = self._default_tray_index + if not 0 <= tray_index < self.num_nests: + 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): + 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_index)) + + 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)) diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py new file mode 100644 index 00000000000..a6b90f673c4 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -0,0 +1,205 @@ +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, + default_tray_index: int = 0, + 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). + default_tray_index: 0-based tray the :class:`AutomatedRetrieval` capability + uses when no ``tray_index`` is given (0 or 1). + """ + 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, default_tray_index=default_tray_index, 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(), + "io": self.io.serialize(), + "motion_timeout": self._motion_timeout, + "default_tray_index": self.automated_retrieval.default_tray_index, + } + + # --- 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/high_res/sample_storage/driver/humidity.py b/pylabrobot/high_res/sample_storage/driver/humidity.py new file mode 100644 index 00000000000..45d176a1f2e --- /dev/null +++ b/pylabrobot/high_res/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/high_res/sample_storage/driver/protocol.py b/pylabrobot/high_res/sample_storage/driver/protocol.py new file mode 100644 index 00000000000..a55bb07e270 --- /dev/null +++ b/pylabrobot/high_res/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/high_res/sample_storage/driver/temperature.py b/pylabrobot/high_res/sample_storage/driver/temperature.py new file mode 100644 index 00000000000..c82b9a6b75f --- /dev/null +++ b/pylabrobot/high_res/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/high_res/sample_storage/errors.py b/pylabrobot/high_res/sample_storage/errors.py new file mode 100644 index 00000000000..1966cbccd87 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/errors.py @@ -0,0 +1,70 @@ +from typing import List + + +class HighResSampleStorageError(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 HighResSampleStorageAbortedError(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") + + +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:`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 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 + usable until recovered — call + :meth:`HighResSampleStorageAutomatedRetrievalBackend.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:`HighResSampleStorageAutomatedRetrievalBackend.recover`.""" + blob = " ".join(error_lines).lower() + return any(sig in blob for sig in _UNSAFE_SIGNATURES) diff --git a/pylabrobot/high_res/sample_storage/sample_storage.py b/pylabrobot/high_res/sample_storage/sample_storage.py new file mode 100644 index 00000000000..249a324067b --- /dev/null +++ b/pylabrobot/high_res/sample_storage/sample_storage.py @@ -0,0 +1,141 @@ +import warnings +from typing import List, Optional + +from pylabrobot.capabilities.automated_retrieval import RandomAccessRetrieval +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, + PlateCarrier, + PlateHolder, + Rotation, +) +from pylabrobot.resources.resource import Resource + +from .driver import HighResSampleStorageDriver + + +class _HighResSampleStorage(Resource, Device): + """Base device for HighRes Biosolutions sample stores. + + 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:`RandomAccessRetrieval` 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, + driver: HighResSampleStorageDriver, + racks: List[PlateCarrier], + nest_locations: List[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] = None, + ): + """ + 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``. + """ + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model or self._model_name, + ) + Device.__init__(self, driver=driver) + self.driver: HighResSampleStorageDriver = driver + + 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: + self.assign_child_resource(rack, location=None) + + self.retrieval = RandomAccessRetrieval( + backend=driver.automated_retrieval, racks=self._racks, loading_trays=self.nests + ) + 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]: + return self._racks + + async def setup(self, backend_params: Optional[BackendParams] = None): + await super().setup(backend_params=backend_params) + await self.driver.automated_retrieval.set_racks(self._racks) + + def serialize(self) -> dict: + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "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/high_res/sample_storage/settings.py b/pylabrobot/high_res/sample_storage/settings.py new file mode 100644 index 00000000000..6d3ba32d5ef --- /dev/null +++ b/pylabrobot/high_res/sample_storage/settings.py @@ -0,0 +1,697 @@ +"""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:`HighResSampleStorageSettings` is loaded whole from the +device (or a capture) and is frozen once built. +""" + +import warnings +from dataclasses import dataclass, fields +from typing import Dict, Iterable, 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",) + + +@dataclass(frozen=True) +class HighResSampleStorageSettings: + """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 + + vac_7_enable: int + vac_8_enable: int + vac_7_purge: int + vac_8_purge: int + + lift_7_enable: int + lift_8_enable: int + + nest_7_sense: int + nest_8_sense: int + + active_hotels: int + + 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 + + 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 + + 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 + + def_plate_height: float + def_stack_height: float + def_plate_thickness: float + + jiggle_count: int + jiggle_size: int + + has_lock_sensor: str + + lock_sensor_input: int + + 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 + + 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 + + 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 + + max_transparency_width_um: int + + spatula_lock_position: float + spatula_base_position: float + + 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 + + spatula_slide_safe_position: float + spatula_slide_barcode_position: float + + 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_lines(cls, lines: Iterable[str]) -> "HighResSampleStorageSettings": + """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): + key = f.name.upper() + if key not in data: + missing.append(key) + continue + 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): " + + ", ".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) diff --git a/pylabrobot/high_res/sample_storage/tests/__init__.py b/pylabrobot/high_res/sample_storage/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py new file mode 100644 index 00000000000..b49b4ad6d54 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -0,0 +1,177 @@ +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_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() + 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.request_spatula_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.request_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_index_selects_nest(self): + # default_tray_index is 0-based; tray 1 -> device nest 2. + driver = HighResSampleStorageDriver(host="10.253.253.253", default_tray_index=1) + self.assertEqual(driver.automated_retrieval.default_tray_index, 1) + 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/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py new file mode 100644 index 00000000000..b1b6698fde8 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -0,0 +1,182 @@ +import unittest +from typing import List + +from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver +from pylabrobot.highres.sample_storage.errors import HighResSampleStorageFault, PlateNotFoundError + + +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 HighResSampleStorageRecoveryTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + 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). + 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.driver.io = sock # type: ignore[assignment] + with self.assertRaises(PlateNotFoundError): + await self.retrieval.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 -> HighResSampleStorageFault (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.driver.io = sock # type: ignore[assignment] + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval.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.driver.io = sock # type: ignore[assignment] + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval.pick(5, 24, 1) + self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) + + 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_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.request_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( + [ + ("enable", _ok("enable", 1)), + ("spatulaout", _ok("spatulaout", 2)), + ("home", _ok("home", 3)), + ("homedstatus", self._homed(4)), + ("status", self._status(5, 0.0)), + ] + ) + 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): + # 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.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.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): + sock = ScriptedSocket( + [ + ("place 2 5 1", _ok("place 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + 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): + sock = ScriptedSocket( + [ + ("pick 2 5 1", _ok("pick 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + 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"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/high_res/sample_storage/types.py b/pylabrobot/high_res/sample_storage/types.py new file mode 100644 index 00000000000..c5a7a7e64ff --- /dev/null +++ b/pylabrobot/high_res/sample_storage/types.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal # type: ignore + + +# 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") + +# State of a transfer nest, as reported by ``neststatus``. +NestState = Literal["clear", "occupied", "unknown"] +NEST_STATES: Tuple[NestState, ...] = ("clear", "occupied", "unknown") + + +@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 StackerDimensions: + """One stacker's geometry, from ``getstackerdimensions``.""" + + stacker: int + zero_offset: float + slot_height: float + slot_count: int From 6556314597d2b330dff5bd87a60133c08049f359 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 24 Aug 2026 19:45:57 -0700 Subject: [PATCH 2/8] feat(high_res): complete sample storage drivers --- docs/_static/devices.json | 51 ++ docs/api/pylabrobot.high_res.rst | 11 + docs/user_guide/high_res/index.md | 1 + .../ambistore/hello-world.ipynb | 342 ++++++++ .../high_res/sample-storage/events.md | 132 +++ .../high_res/sample-storage/index.md | 127 +++ .../steristore/hello-world.ipynb | 394 +++++++++ .../tundrastore/hello-world.ipynb | 388 +++++++++ .../high_res/sample_storage/__init__.py | 28 +- .../high_res/sample_storage/ambi_store.py | 15 + .../sample_storage/driver/__init__.py | 23 +- .../driver/automated_retrieval.py | 303 ------- .../high_res/sample_storage/driver/driver.py | 813 ++++++++++++++++-- .../sample_storage/driver/environment.py | 239 +++++ .../sample_storage/{ => driver}/errors.py | 23 +- .../sample_storage/driver/humidity.py | 29 - .../high_res/sample_storage/driver/models.py | 72 ++ .../sample_storage/driver/protocol.py | 3 +- .../sample_storage/driver/settings.py | 710 +++++++++++++++ .../sample_storage/driver/temperature.py | 32 - .../sample_storage/{ => driver}/types.py | 0 .../high_res/sample_storage/sample_storage.py | 141 --- .../high_res/sample_storage/settings.py | 697 --------------- .../high_res/sample_storage/steri_store.py | 7 + .../sample_storage/tests/driver_tests.py | 781 ++++++++++++++++- .../sample_storage/tests/recovery_tests.py | 95 +- .../high_res/sample_storage/tundra_store.py | 11 + 27 files changed, 4120 insertions(+), 1348 deletions(-) create mode 100644 docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb create mode 100644 docs/user_guide/high_res/sample-storage/events.md create mode 100644 docs/user_guide/high_res/sample-storage/index.md create mode 100644 docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb create mode 100644 docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb create mode 100644 pylabrobot/high_res/sample_storage/ambi_store.py delete mode 100644 pylabrobot/high_res/sample_storage/driver/automated_retrieval.py create mode 100644 pylabrobot/high_res/sample_storage/driver/environment.py rename pylabrobot/high_res/sample_storage/{ => driver}/errors.py (78%) delete mode 100644 pylabrobot/high_res/sample_storage/driver/humidity.py create mode 100644 pylabrobot/high_res/sample_storage/driver/models.py create mode 100644 pylabrobot/high_res/sample_storage/driver/settings.py delete mode 100644 pylabrobot/high_res/sample_storage/driver/temperature.py rename pylabrobot/high_res/sample_storage/{ => driver}/types.py (100%) delete mode 100644 pylabrobot/high_res/sample_storage/sample_storage.py delete mode 100644 pylabrobot/high_res/sample_storage/settings.py create mode 100644 pylabrobot/high_res/sample_storage/steri_store.py create mode 100644 pylabrobot/high_res/sample_storage/tundra_store.py diff --git a/docs/_static/devices.json b/docs/_static/devices.json index c6ec378ff72..16eade89a54 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -542,6 +542,22 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.hettweb.com/products/sbs300robotic/" }, + { + "id": "highres-ambistore", + "vendor": "HighRes Biosolutions", + "name": "AmbiStore", + "kind": "storage", + "capabilities": [ + "storage" + ], + "status": "wip", + "api": "pylabrobot.high_res.sample_storage.AmbiStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/ambistore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, { "id": "highres-lid-valet", "vendor": "HighRes Biosolutions", @@ -573,6 +589,41 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.highres.com/lab-instruments/microspin-centrifuge" }, + { + "id": "highres-steristore", + "vendor": "HighRes Biosolutions", + "name": "SteriStore", + "kind": "storage", + "capabilities": [ + "storage", + "heating", + "active cooling" + ], + "status": "full", + "api": "pylabrobot.high_res.sample_storage.SteriStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/steristore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, + { + "id": "highres-tundrastore", + "vendor": "HighRes Biosolutions", + "name": "TundraStore", + "kind": "storage", + "capabilities": [ + "storage", + "active cooling" + ], + "status": "wip", + "api": "pylabrobot.high_res.sample_storage.TundraStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/tundrastore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, { "id": "inheco-cpac", "vendor": "Inheco", diff --git a/docs/api/pylabrobot.high_res.rst b/docs/api/pylabrobot.high_res.rst index 9b66cf4eea1..c851949bc51 100644 --- a/docs/api/pylabrobot.high_res.rst +++ b/docs/api/pylabrobot.high_res.rst @@ -12,3 +12,14 @@ pylabrobot.high_res package HighResLidValet HighResLidValetError + +.. currentmodule:: pylabrobot.high_res.sample_storage + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + AmbiStore + EnvironmentControl + SteriStore + TundraStore diff --git a/docs/user_guide/high_res/index.md b/docs/user_guide/high_res/index.md index c2d180c7347..27033a36794 100644 --- a/docs/user_guide/high_res/index.md +++ b/docs/user_guide/high_res/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 lid-valet/hello-world +sample-storage/index ``` diff --git a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb new file mode 100644 index 00000000000..64fab83643d --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb @@ -0,0 +1,342 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes AmbiStore\n", + "\n", + "```{device-card} highres-ambistore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | AmbiStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | Ambient |\n", + "| Driver support | Work in progress; not hardware-verified |\n", + "\n", + "```{warning}\n", + "AmbiStore support has not been verified against AmbiStore hardware. Run motion only in a controlled setup and report verified behavior.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import AmbiStore\n", + "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "\n", + "site = PlateHolder(\n", + " name=\"stacker_1_slot_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=25.0,\n", + " pedestal_size_z=0,\n", + ")\n", + "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", + "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = [rack]" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = AmbiStore(host=\"192.168.127.60\", name=\"ambistore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/high_res/sample-storage/events.md b/docs/user_guide/high_res/sample-storage/events.md new file mode 100644 index 00000000000..6483d7ae8f1 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/events.md @@ -0,0 +1,132 @@ +# Sample-storage events + +HighRes sample stores emit structured events for plate transfers and changes to environmental +control. Event observation is optional and does not change device behavior. + +Every operation listed below emits one of these correlated lifecycle sequences when an active +EventBus has a subscriber: + +```text +.started -> .completed +.started -> .failed +``` + +The two records share `context.operation_id`. A failed record also contains `error_type` and +`error_message`. + +## Quickstart + +Subscribe after setting up the store, then wrap the operations that should be observed: + +```python +from pylabrobot.events import EventBus, use_event_bus + +event_bus = EventBus() +event_bus.subscribe(lambda event: print(event.as_dict())) + +await store.setup() + +with use_event_bus(event_bus): + await store.fetch_plate_to_loading_tray("plate_1", tray_index=0) + await store.environment.set_temperature(37) +``` + +Use `set_default_event_bus()` instead when one process-wide bus should observe every instrumented +operation. See the general [EventBus guide](../../machine-agnostic-features/event-bus.md) for event +shape, application context, and subscriber behavior. + +## Transfer events + +| Operation | Emitted by | Fields | +| --- | --- | --- | +| `incubator.fetch_plate` | `fetch_plate_to_loading_tray()` | `device`, `resources`, `source`, `destination` | +| `incubator.take_in_plate` | `take_in_plate()` and `store_plate()` | `device`, `resources`, `source`, `destination` | +| `incubator.transfer_plate` | `transfer_plate_between_nests()` | `device`, `resources`, `source`, `destination` | + +`resources` contains the plate being moved. `source` and `destination` identify the actual +`PlateHolder` resources: a stacker slot and a device-reported transfer nest. + +The transfer event encloses both the hardware command and PLR bookkeeping. A successful transfer +therefore produces this sequence, all with the same `operation_id`: + +```text +incubator.fetch_plate.started +resource.unassigned +resource.assigned +incubator.fetch_plate.completed +``` + +For `take_in_plate()` and `store_plate()`, the equivalent outer event is +`incubator.take_in_plate`; nest-to-nest moves use `incubator.transfer_plate`. If validation or +hardware motion fails after the operation begins, the plate remains at its original PLR location +and the outer operation emits `.failed`. + +Example invocation data: + +```python +{ + "device": {"name": "steristore", "type": "SteriStore", "model": "SteriStore"}, + "resources": [{"name": "plate_1", "type": "Plate"}], + "source": {"name": "rack_1_slot_3", "type": "PlateHolder"}, + "destination": {"name": "steristore_nest_1", "type": "PlateHolder"}, +} +``` + +Resource references may also contain rotation and ancestor information. + +## Environmental-control events + +Environmental events identify the sample store in `device` and use an empty `resources` list. +Humidity and gas targets are fractions, matching the Python API: `0.90` means 90% RH and `0.05` +means 5% gas concentration. + +| Control | Models | +| --- | --- | +| Temperature | SteriStore and TundraStore | +| Relative humidity | SteriStore and TundraStore | +| CO2 | SteriStore | +| O2 | SteriStore when the optional controllable channel is installed | + +AmbiStore does not expose `store.environment`, so it emits only transfer events. + +| Operation | Emitted by | Additional invocation fields | +| --- | --- | --- | +| `temperature_controller.set_temperature` | `set_temperature()` | `target_temperature`, `passive=False` | +| `temperature_controller.activate` | `start_temperature_control()` | — | +| `temperature_controller.deactivate` | `stop_temperature_control()` | — | +| `humidity_controller.set_humidity` | `set_humidity()` | `target_humidity` | +| `humidity_controller.activate` | `start_humidity_control()` | — | +| `humidity_controller.deactivate` | `stop_humidity_control()` | — | +| `co2_controller.set_co2` | `set_co2()` | `target_co2` | +| `co2_controller.activate` | `start_co2_control()` | — | +| `co2_controller.deactivate` | `stop_co2_control()` | — | +| `o2_controller.set_o2` | `set_o2()` | `target_o2` | +| `o2_controller.activate` | `start_o2_control()` | — | +| `o2_controller.deactivate` | `stop_o2_control()` | — | + +For example, `await store.environment.set_co2(0.05)` emits +`co2_controller.set_co2.started` followed by either `co2_controller.set_co2.completed` or +`co2_controller.set_co2.failed`: + +```python +{ + "device": {"name": "steristore", "type": "SteriStore", "model": "SteriStore"}, + "resources": [], + "target_co2": 0.05, +} +``` + +Calling an unavailable channel is observable as a failed operation. For example, attempting O2 +control on a store without an installed controllable O2 channel emits +`o2_controller.set_o2.failed` with `error_type="NotImplementedError"`. + +## Operations without semantic events + +Read-only status methods do not emit semantic operation events. This includes environmental +reads, tank-pressure reads, version and status requests, and inventory queries. `setup()`, +`stop()`, homing, recovery, door control, and clear-abort are also not currently +instrumented as semantic operations. + +Generic `resource.assigned` and `resource.unassigned` state events can still be emitted whenever +PLR resource bookkeeping changes, including nest creation during initial setup when an EventBus is +active. diff --git a/docs/user_guide/high_res/sample-storage/index.md b/docs/user_guide/high_res/sample-storage/index.md new file mode 100644 index 00000000000..8287c13a3b2 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/index.md @@ -0,0 +1,127 @@ +# Sample storage + +The HighRes Biosolutions AmbiStore, SteriStore, and TundraStore use the same TCP command protocol +and share a PyLabRobot interface. Choose the concrete class for the model you are configuring; the +product name reported by firmware is retained as version information and does not override that +choice. + +```{toctree} +:maxdepth: 1 + +ambistore/hello-world +steristore/hello-world +tundrastore/hello-world +events +``` + +## Models + +| Model | Environment | Verification | +| --- | --- | --- | +| [AmbiStore](ambistore/hello-world.ipynb) | Ambient storage | Work in progress; not hardware-verified | +| [SteriStore](steristore/hello-world.ipynb) | Heating, active cooling, humidity, CO2, and optional O2 | Hardware-verified | +| [TundraStore](tundrastore/hello-world.ipynb) | Refrigeration and temperature-dependent humidity control | Work in progress; not hardware-verified | + +The published temperature ranges and environmental options come from the current +[HighRes sample-storage page](https://www.highres.com/lab-instruments/sample-storage) and an +[archived HighRes sample-storage brochure](https://7157e75ac0509b6a8f5c-5b19c577d01b9ccfe75d2f9e4b17ab55.ssl.cf1.rackcdn.com/RAXZFZSW-PDF-2-613050-4526550137.pdf). + +## Network connection + +The remote-control server listens on TCP port 1000. The normal factory address is +`192.168.127.60`; HighRes devices also expose the service at `10.253.253.253`. Give the dedicated +host Ethernet interface an address on both subnets so either address remains reachable: + +```bash +sudo ip address replace 192.168.127.50/24 dev +sudo ip address replace 10.253.253.250/24 dev +``` + +These `ip address` changes are temporary and disappear when the USB Ethernet adapter is unplugged +or the host restarts. On Linux systems managed by NetworkManager, create a persistent connection +profile instead: + +```bash +sudo nmcli connection add \ + type ethernet \ + ifname \ + con-name highres-sample-storage \ + ipv4.method manual \ + ipv4.addresses "192.168.127.50/24,10.253.253.250/24" \ + ipv4.never-default yes \ + ipv6.method disabled +sudo nmcli connection up highres-sample-storage +``` + +Use `ip -brief link` to find ``. Verify the isolated link with +`ping 10.253.253.253`; do not add a gateway or default route to this connection. + +## Setup + +Pass the storage racks in stacker order and instantiate the appropriate model. For example: + +```python +from pylabrobot.high_res.sample_storage import SteriStore + +store = SteriStore(host="192.168.127.60", name="steristore", racks=racks) +await store.setup() +``` + +Each rack maps to its one-based device stacker by list position. Within a rack, the zero-based +carrier `spot` maps to the one-based physical slot: spot 0 is device slot 1, spot 1 is slot 2, and +so on. Dictionary insertion order does not affect this mapping. + +During setup, the device-reported transfer nests become `store.nests`. Their locations relative to +the store are left undefined because they depend on the surrounding robot installation. Setup does +not invent plate resources for occupied nests; assign any already-present plates to the matching +nest after setup. + +## Plate transfers + +Fetch a known plate from its stacker slot to a transfer nest: + +```python +plate = await store.fetch_plate_to_loading_tray("plate_1", tray_index=0) +``` + +Move the plate on a transfer nest back into storage, choosing either a specific `PlateHolder`, the +smallest available site, or a random available site: + +```python +await store.take_in_plate(tray_index=0, site="smallest") +``` + +Both operations update the PLR resource tree only after successful hardware motion. See +[Sample-storage events](events.md) for their structured execution events. + +Move a plate directly between two transfer nests using their zero-based tray indices: + +```python +plate = await store.transfer_plate_between_nests( + source_tray_index=1, + destination_tray_index=0, +) +``` + +The driver verifies the live source and destination sensors before every fetch, store, or +nest-to-nest transfer. A mismatch between the physical device and the PLR resource tree stops the +operation before motion begins. + +## Barcode scans + +Barcode scans require every transfer nest to be clear. The driver checks this before starting the +scan because firmware 3.0.0.119 otherwise waits for an automation door and eventually times out. + +```python +barcodes = await store.request_stacker_barcodes(2) +barcode = await store.request_stacker_barcodes(2, slot=1) +``` + +A returned value of `EMPTY` means that the scanner did not read a barcode. It does not prove the +physical slot is empty; use resource bookkeeping or a physical plate-presence workflow for that. + +## Recovery + +`request_is_parked()` verifies that the device is homed and that both the spatula slide and lift +axes are retracted. `recover()` refuses to move when the spatula sensor reports a plate, because +the plate's physical support must be inspected before a safe recovery path can be chosen. diff --git a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb new file mode 100644 index 00000000000..8fea7b8fb71 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb @@ -0,0 +1,394 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes SteriStore\n", + "\n", + "```{device-card} highres-steristore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | SteriStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | 4–80 °C; optional 100 °C, humidity, CO2, optional O2 |\n", + "| Driver support | Hardware-verified |\n", + "\n", + "```{warning}\n", + "Only enable gas control after its supply and exhaust/ventilation are correctly connected.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import SteriStore\n", + "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "\n", + "site = PlateHolder(\n", + " name=\"stacker_1_slot_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=25.0,\n", + " pedestal_size_z=0,\n", + ")\n", + "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", + "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = [rack]" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = SteriStore(host=\"192.168.127.60\", name=\"steristore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "environment-1-text", + "metadata": {}, + "source": [ + "## Read environmental state\n", + "\n", + "Read the installed channels and their current values. Humidity and gas concentrations are returned as fractions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-1-code", + "metadata": {}, + "outputs": [], + "source": [ + "environment = await store.environment.refresh()\n", + "temperature = await store.environment.request_current_temperature()\n", + "humidity = await store.environment.request_current_humidity()\n", + "print(temperature, humidity, environment)" + ] + }, + { + "cell_type": "markdown", + "id": "environment-2-text", + "metadata": {}, + "source": [ + "## Confirm environmental setpoints\n", + "\n", + "This writes each existing target back unchanged, exercising the setters without changing the requested conditions. Installed channel and temperature limits are checked first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-2-code", + "metadata": {}, + "outputs": [], + "source": [ + "temperature_target = await store.environment.request_target_temperature()\n", + "await store.environment.set_temperature(temperature_target)\n", + "humidity_target = await store.environment.request_target_humidity()\n", + "await store.environment.set_humidity(humidity_target)\n", + "if store.environment.supports_co2_control:\n", + " co2_target = await store.environment.request_target_co2()\n", + " await store.environment.set_co2(co2_target)\n", + "if store.environment.supports_o2_control:\n", + " o2_target = await store.environment.request_target_o2()\n", + " await store.environment.set_o2(o2_target)" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb new file mode 100644 index 00000000000..1132bd1438d --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb @@ -0,0 +1,388 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes TundraStore\n", + "\n", + "```{device-card} highres-tundrastore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | TundraStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | −20 to 4 °C; temperature-dependent humidity |\n", + "| Driver support | Work in progress; not hardware-verified |\n", + "\n", + "```{warning}\n", + "TundraStore support has not been verified against TundraStore hardware. Validate temperature, humidity, and motion in a controlled setup.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import TundraStore\n", + "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "\n", + "site = PlateHolder(\n", + " name=\"stacker_1_slot_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=25.0,\n", + " pedestal_size_z=0,\n", + ")\n", + "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", + "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = [rack]" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = TundraStore(host=\"192.168.127.60\", name=\"tundrastore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "environment-1-text", + "metadata": {}, + "source": [ + "## Read environmental state\n", + "\n", + "Read the installed channels and current temperature and humidity. Humidity is returned as a fraction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-1-code", + "metadata": {}, + "outputs": [], + "source": [ + "environment = await store.environment.refresh()\n", + "temperature = await store.environment.request_current_temperature()\n", + "humidity = await store.environment.request_current_humidity()\n", + "print(temperature, humidity, environment)" + ] + }, + { + "cell_type": "markdown", + "id": "environment-2-text", + "metadata": {}, + "source": [ + "## Confirm environmental setpoints\n", + "\n", + "This writes the existing targets back unchanged, exercising validation without changing the requested conditions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-2-code", + "metadata": {}, + "outputs": [], + "source": [ + "temperature_target = await store.environment.request_target_temperature()\n", + "await store.environment.set_temperature(temperature_target)\n", + "humidity_target = await store.environment.request_target_humidity()\n", + "await store.environment.set_humidity(humidity_target)" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py index 17997bf53da..3ca082c4395 100644 --- a/pylabrobot/high_res/sample_storage/__init__.py +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -1,23 +1,21 @@ +from .ambi_store import AmbiStore from .driver import ( - HighResSampleStorageAutomatedRetrievalBackend, - HighResSampleStorageDriver, - HighResSampleStorageHumidityControllerBackend, - HighResSampleStorageTemperatureControllerBackend, -) -from .errors import ( - PlateNotFoundError, + DoorState, + EnvironmentControl, + EnvironmentParameter, + HighResSampleStorage, HighResSampleStorageAbortedError, HighResSampleStorageError, HighResSampleStorageFault, -) -from .settings import MachineType, HighResSampleStorageSettings -from .types import ( - DoorState, - EnvironmentParameter, + HighResSampleStorageProtocolError, + HighResSampleStorageSettings, + MachineType, + ModelInfo, NestState, + NoFreeSiteError, + PlateNotFoundError, StackerDimensions, VersionInfo, ) -from pylabrobot.capabilities.automated_retrieval import NoFreeSiteError - -from .sample_storage import AmbiStore, SteriStore, TundraStore +from .steri_store import SteriStore +from .tundra_store import TundraStore diff --git a/pylabrobot/high_res/sample_storage/ambi_store.py b/pylabrobot/high_res/sample_storage/ambi_store.py new file mode 100644 index 00000000000..5544a3c7934 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/ambi_store.py @@ -0,0 +1,15 @@ +from .driver import HighResSampleStorage + + +class AmbiStore(HighResSampleStorage): + """HighRes Biosolutions AmbiStore plate store. + + The AmbiStore is ambient, so it exposes only plate storage and retrieval. + Its environment-control behavior has not yet been verified against hardware. + """ + + _model_name = "AmbiStore" + _verification_warning = ( + "AmbiStore support is a work in progress and has not been verified against hardware. " + "Validate it in a controlled setup and report verified behavior so this warning can be removed." + ) diff --git a/pylabrobot/high_res/sample_storage/driver/__init__.py b/pylabrobot/high_res/sample_storage/driver/__init__.py index a262b404768..182f263e7e7 100644 --- a/pylabrobot/high_res/sample_storage/driver/__init__.py +++ b/pylabrobot/high_res/sample_storage/driver/__init__.py @@ -1,4 +1,19 @@ -from .automated_retrieval import HighResSampleStorageAutomatedRetrievalBackend -from .driver import HighResSampleStorageDriver -from .humidity import HighResSampleStorageHumidityControllerBackend -from .temperature import HighResSampleStorageTemperatureControllerBackend +from .driver import HighResSampleStorage +from .environment import EnvironmentControl +from .errors import ( + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + NoFreeSiteError, + PlateNotFoundError, +) +from .models import ModelInfo +from .settings import HighResSampleStorageSettings, MachineType +from .types import ( + DoorState, + EnvironmentParameter, + NestState, + StackerDimensions, + VersionInfo, +) diff --git a/pylabrobot/high_res/sample_storage/driver/automated_retrieval.py b/pylabrobot/high_res/sample_storage/driver/automated_retrieval.py deleted file mode 100644 index 45fd2aefd63..00000000000 --- a/pylabrobot/high_res/sample_storage/driver/automated_retrieval.py +++ /dev/null @@ -1,303 +0,0 @@ -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, cast - -from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend -from pylabrobot.resources import Plate, PlateCarrier, PlateHolder - -from ..errors import ( - HighResSampleStorageError, - HighResSampleStorageFault, - PlateNotFoundError, - left_unsafe, -) -from ..settings import HighResSampleStorageSettings -from ..types import DOOR_STATES, NEST_STATES, DoorState, NestState, StackerDimensions -from .protocol import parse_kv - -if TYPE_CHECKING: - from .driver import HighResSampleStorageDriver - - -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 - *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. - - The store has two nests, exposed through the multi-tray - :class:`AutomatedRetrieval` capability: ``tray_index`` is a 0-based tray index, - and ``tray_index=None`` selects :attr:`default_tray_index`. The 0-based index is - converted to the device's 1-based nest number internally (:meth:`_nest_for_tray`). - - All commands are issued through the owning :class:`HighResSampleStorageDriver`. - """ - - def __init__( - self, - driver: "HighResSampleStorageDriver", - default_tray_index: int = 0, - num_nests: int = 2, - ): - super().__init__() - self._driver = driver - self._default_tray_index = default_tray_index - 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 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]] = {} - - # --- queries (verified against firmware 3.0.0.119) ------------------------ - - 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(): - try: - out[key] = float(value) - except ValueError: - continue - return out - - async def request_is_homed(self) -> bool: - 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 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 - - 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(): - try: - nest = int(key) - except ValueError: - continue - state = value.lower() - nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" - return nests - - 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 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 - ``OCCUPIED``; anything other than ``CLEAR`` counts as holding. - """ - state = (await self.request_nest_status()).get(nest) - 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 - 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:`request_nest_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_stacker_dimensions(self) -> List[StackerDimensions]: - """Parse ``getstackerdimensions`` (``: - ``).""" - dims: List[StackerDimensions] = [] - for line in await self._driver.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 request_settings(self) -> HighResSampleStorageSettings: - """Read the device's full settings file (``NAME = value`` pairs) into a - frozen :class:`HighResSampleStorageSettings`.""" - lines = await self._driver.send_command("settings", timeout=self._driver.read_timeout) - return HighResSampleStorageSettings.from_lines(lines) - - async def request_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._driver.send_command(command, timeout=self._driver.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:`HighResSampleStorageError` ("Unable to close all doors").""" - await self._driver.send_command("home", timeout=self._driver.motion_timeout) - - 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:`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.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 - - async def recover(self) -> bool: - """Retract the spatula and re-home after a motion fault. - - 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:`request_is_parked`. - """ - for _ in range(3): - for command in ("enable", "spatulaout"): - try: - await self._driver.send_command(command, timeout=self._driver.motion_timeout) - except HighResSampleStorageError: - pass - try: - await self._driver.send_command("home", timeout=self._driver.motion_timeout) - except HighResSampleStorageError: - pass - if await self.request_is_parked(): - return True - return False - - 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") - and the store retracted cleanly; the machine is safe to keep using. - - :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. - - 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:`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.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 - 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)``. - - 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._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._driver.send_command("openalldoors", timeout=self._driver.motion_timeout) - - async def close_all_doors(self): - 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._driver.send_command("abort") - - async def clear_abort(self): - await self._driver.send_command("clearabort") - - # --- AutomatedRetrieval capability ---------------------------------------- - - 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``.""" - 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] - - @property - def default_tray_index(self) -> int: - """0-based tray index used when ``tray_index`` is ``None`` (see the base backend).""" - return self._default_tray_index - - def _nest_for_tray(self, tray_index: Optional[int]) -> int: - """Map a 0-based capability tray index to the device's 1-based nest number. - - ``None`` selects :attr:`default_tray_index` (the configured default tray).""" - if tray_index is None: - tray_index = self._default_tray_index - if not 0 <= tray_index < self.num_nests: - 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): - 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_index)) - - 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)) diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index a6b90f673c4..f06b8ba93de 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -1,16 +1,30 @@ import asyncio import logging -from dataclasses import dataclass -from typing import Dict, List, Optional +import random +from typing import Dict, List, Literal, Optional, Tuple, Union, cast -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.device import Driver +from pylabrobot.events import event_operation, resource_reference from pylabrobot.io.socket import Socket +from pylabrobot.resources import ( + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) -from ..errors import HighResSampleStorageAbortedError, HighResSampleStorageError -from ..types import EnvironmentParameter, VersionInfo -from .automated_retrieval import HighResSampleStorageAutomatedRetrievalBackend -from .humidity import HighResSampleStorageHumidityControllerBackend +from .environment import EnvironmentControl +from .errors import ( + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + NoFreeSiteError, + PlateNotFoundError, + left_unsafe, +) +from .models import ModelInfo, get_model_info from .protocol import ( ACK_TOKEN, COMPLETION_ABORTED, @@ -19,40 +33,58 @@ COMPLETION_TOKENS, parse_kv, ) -from .temperature import HighResSampleStorageTemperatureControllerBackend +from .settings import HighResSampleStorageSettings +from .types import ( + DOOR_STATES, + NEST_STATES, + DoorState, + EnvironmentParameter, + NestState, + StackerDimensions, + VersionInfo, +) logger = logging.getLogger(__name__) -class HighResSampleStorageDriver(Driver): - """Transport for HighRes Biosolutions sample stores (TundraStore / SteriStore - / AmbiStore). +class HighResSampleStorage(Resource): + """Base device for HighRes Biosolutions sample stores. + + 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). Fetch and store operations address a particular nest + with a 0-based ``tray_index``. + + Subclasses set :attr:`_model_name`; callers can override it with ``model``. + This configured model selects model-specific behavior. The product name + reported by the device is logged during setup but is not used as configuration. 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 + _model_name = "HighResSampleStorage" + _verification_warning: Optional[str] = None def __init__( self, host: str, + name: str, + racks: List[PlateCarrier], + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = "plate_store", + model: Optional[str] = None, port: int = 1000, read_timeout: float = 30.0, motion_timeout: float = 240.0, - default_tray_index: int = 0, - num_nests: int = 2, ): """ Args: @@ -62,10 +94,47 @@ def __init__( read_timeout: Timeout (s) for query/status commands. motion_timeout: Timeout (s) for long-running motion commands (``home``, ``pick``, ``place``, door moves). - default_tray_index: 0-based tray the :class:`AutomatedRetrieval` capability - uses when no ``tray_index`` is given (0 or 1). + model: Model used for model-specific behavior. Defaults to the concrete + class's model and is never replaced with the device-reported product name. """ - super().__init__() + configured_model = model if model is not None else self._model_name + model_info = get_model_info(configured_model) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=configured_model, + ) + + # The device reports its configured nest numbers during setup. Their + # robot-facing coordinates are intentionally undefined relative to the + # store, so the corresponding resources are attached with location=None. + self.nests: List[PlateHolder] = [] + self._nest_numbers: List[int] = [] + + self._racks = racks + self._site_locations: Dict[int, Tuple[int, int]] = {} + for rack_index, rack in enumerate(self._racks): + self.assign_child_resource(rack, location=None) + for spot, site in rack.sites.items(): + if spot < 0: + raise ValueError(f"Rack site spot must be non-negative; got {spot} for {site.name!r}.") + # PLR carrier spots are zero-based; HighRes stacker slots are one-based. + self._site_locations[id(site)] = (rack_index + 1, spot + 1) + + # Slide (Y) and lift (Z) positions near zero are retracted. Faulted moves + # can leave either axis extended even when firmware still reports homed. + self._retracted_y_max = 50.0 + self._retracted_z_max = 50.0 + + self._model_info = model_info + if self._model_info.has_environment_control: + self.environment = EnvironmentControl(driver=self) + self.io = Socket( human_readable_device_name="HighRes sample store", host=host, @@ -76,12 +145,10 @@ def __init__( self._read_timeout = read_timeout self._motion_timeout = motion_timeout self._command_lock = asyncio.Lock() - - self.automated_retrieval = HighResSampleStorageAutomatedRetrievalBackend( - self, default_tray_index=default_tray_index, num_nests=num_nests - ) - self.temperature = HighResSampleStorageTemperatureControllerBackend(self) - self.humidity = HighResSampleStorageHumidityControllerBackend(self) + # A command lock prevents protocol responses from interleaving, but a plate + # transfer also includes resource validation and bookkeeping on either side + # of the hardware command. Keep that entire transaction atomic. + self._transfer_lock = asyncio.Lock() @property def read_timeout(self) -> float: @@ -91,23 +158,26 @@ def read_timeout(self) -> float: def motion_timeout(self) -> float: return self._motion_timeout + @property + def model_info(self) -> ModelInfo: + return self._model_info + def serialize(self) -> dict: - return { - **super().serialize(), - "io": self.io.serialize(), - "motion_timeout": self._motion_timeout, - "default_tray_index": self.automated_retrieval.default_tray_index, - } + raise NotImplementedError("HighRes sample store serialization is not implemented yet.") # --- 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}") - + async def setup(self, home: bool = False) -> None: + if self._verification_warning is not None: + logger.warning("%s", self._verification_warning) await self.io.setup() + try: + await self._setup_connected(home=home) + except BaseException: + await self.io.stop() + raise + + async def _setup_connected(self, home: bool) -> None: version = await self.request_version() logger.info( "Connected to %s (serial %s, firmware %s)", @@ -115,10 +185,30 @@ async def setup(self, backend_params: Optional[BackendParams] = None): version.serial_number, version.firmware_version, ) - if backend_params.home_on_setup: - await self.automated_retrieval.home() + + if self._model_info.has_environment_control: + await self.environment.refresh() + + nest_status = await self.request_nest_status() + if not nest_status: + raise RuntimeError("The sample store did not report any nests.") + if not self.nests: + self._nest_numbers = sorted(nest_status) + for nest_number in self._nest_numbers: + nest = PlateHolder( + name=f"{self.name}_nest_{nest_number}", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + ) + self.assign_child_resource(nest, location=None) + self.nests.append(nest) + if home: + await self.home() async def stop(self): + logger.info("Stopping %s", self.name) await self.io.stop() # --- transport ------------------------------------------------------------ @@ -134,35 +224,86 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L Raises: HighResSampleStorageError: if the device replies ``ERROR!``. HighResSampleStorageAbortedError: if the device replies ``ABORTED!``. + HighResSampleStorageProtocolError: if the acknowledgement or completion + does not correspond exactly to this command. """ + if not command or command != command.strip() or "\r" in command or "\n" in command: + raise ValueError("command must be a non-empty single line without surrounding whitespace") if timeout is None: timeout = self._read_timeout + encoded_command = command.encode("ascii") + b"\r\n" 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 + try: + await self.io.write(encoded_command) + + data_lines: List[str] = [] + ack = await self._readline(timeout) + ack_command, command_id = self._parse_envelope(ACK_TOKEN, ack, command) + if ack_command != command: + raise HighResSampleStorageProtocolError( + command, ack, f"ACK echoed command {ack_command!r}" + ) + + while True: + line = await self._readline(timeout) + completion_token = next( + (token for token in COMPLETION_TOKENS if line == token or line.startswith(f"{token} ")), + None, + ) + if completion_token is None: + if line == ACK_TOKEN or line.startswith(f"{ACK_TOKEN} "): + raise HighResSampleStorageProtocolError(command, line, "received a second ACK") + data_lines.append(line) + continue + + completion_command, completion_id = self._parse_envelope(completion_token, line, command) + if completion_command != command: + raise HighResSampleStorageProtocolError( + command, line, f"completion echoed command {completion_command!r}" + ) + if completion_id != command_id: + raise HighResSampleStorageProtocolError( + command, + line, + f"completion command ID {completion_id!r} does not match ACK ID {command_id!r}", + ) break - data_lines.append(line) + except BaseException: + # A timeout, cancellation, or malformed envelope can leave unread response + # lines in the stream. Closing prevents the next command from consuming + # those stale lines as its own response. + logger.exception( + "Invalidating %s connection after incomplete command %r", self.name, command + ) + try: + await self.io.stop() + except BaseException: + logger.exception("Failed to close invalid %s connection", self.name) + raise - if completion.startswith(COMPLETION_ERROR): + if completion_token == 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): + if completion_token == COMPLETION_ABORTED: raise HighResSampleStorageAbortedError(command) - assert completion.startswith(COMPLETION_OK) + if completion_token != COMPLETION_OK: + raise HighResSampleStorageProtocolError(command, line, "unknown completion status") return data_lines + @staticmethod + def _parse_envelope(token: str, line: str, command: str) -> Tuple[str, str]: + prefix = f"{token} " + if not line.startswith(prefix): + raise HighResSampleStorageProtocolError(command, line, f"expected {token} envelope") + echoed_command, separator, command_id = line[len(prefix) :].rpartition(" ") + if not separator or not echoed_command or not command_id.isdecimal(): + raise HighResSampleStorageProtocolError( + command, line, f"expected '{token} '" + ) + return echoed_command, command_id + # --- shared device queries ------------------------------------------------ async def request_version(self) -> VersionInfo: @@ -180,7 +321,7 @@ async def request_environment(self) -> Dict[str, 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. + temperature and humidity controls. """ out: Dict[str, EnvironmentParameter] = {} for line in await self.send_command("environmentstatus"): @@ -199,7 +340,549 @@ def _opt(i: int, parts=parts) -> Optional[float]: except (ValueError, IndexError): return None - out[name.strip()] = EnvironmentParameter( - name=name.strip(), current=current, setpoint=_opt(1), limit=_opt(2) + channel = name.strip().upper() + out[channel] = EnvironmentParameter( + name=channel, current=current, setpoint=_opt(1), limit=_opt(2) ) return out + + # --- queries -------------------------------------------------------------- + + 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.send_command("status")).items(): + try: + out[key] = float(value) + except ValueError: + continue + return out + + async def request_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) -> Dict[str, DoorState]: + """Parsed ``doorstatus`` output, keyed by door name.""" + doors: Dict[str, DoorState] = {} + for name, value in parse_kv(await self.send_command("doorstatus")).items(): + 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]: + """Parsed ``neststatus`` output, keyed by nest number.""" + nests: Dict[int, NestState] = {} + for key, value in parse_kv(await self.send_command("neststatus")).items(): + try: + nest = int(key) + except ValueError: + continue + state = value.lower() + if state == "plate_available": + state = "occupied" + nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" + return nests + + async def request_spatula_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 request_nest_is_holding(self, nest: int) -> bool: + """Whether a plate is present on ``nest`` (per its plate sensor). + + Firmware ``PLATE_AVAILABLE`` responses are normalized to ``occupied`` by + :meth:`request_nest_status`; any other non-clear state also counts as holding. + """ + states = await self.request_nest_status() + if nest not in states: + raise ValueError(f"The device did not report nest {nest}.") + return states[nest] != "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:`request_nest_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_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 request_settings(self) -> HighResSampleStorageSettings: + """Read the device's full settings file (``NAME = value`` pairs) into a + frozen :class:`HighResSampleStorageSettings`.""" + lines = await self.send_command("settings", timeout=self.read_timeout) + return HighResSampleStorageSettings.from_lines(lines) + + async def request_stacker_barcodes( + self, stacker: Union[int, Literal["all"]], slot: Optional[int] = None + ) -> List[str]: + """Scan a stacker (or a single slot) for barcodes. + + All transfer nests must be clear: firmware 3.0.0.119 otherwise waits for + an automation door that cannot open and eventually reports a timeout. + ``EMPTY`` in the returned lines means no readable barcode; it is not a + plate-presence result. + + Args: + stacker: Stacker number, or the string ``"all"`` to scan the whole + inventory. + slot: Optional single slot to scan. + """ + if stacker != "all" and (not isinstance(stacker, int) or stacker < 1): + raise ValueError("stacker must be a positive integer or 'all'.") + if stacker == "all" and slot is not None: + raise ValueError("slot cannot be specified when stacker is 'all'.") + if slot is not None and slot < 1: + raise ValueError("slot must be a positive integer.") + + occupied_nests = [ + nest for nest, state in (await self.request_nest_status()).items() if state != "clear" + ] + if occupied_nests: + raise RuntimeError( + "Cannot scan barcodes while plates are present on nests " + + ", ".join(map(str, occupied_nests)) + + "; clear all nests first." + ) + + 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:`HighResSampleStorageError` ("Unable to close all doors").""" + if await self.request_spatula_is_holding(): + raise RuntimeError("Cannot home while the spatula reports that it is holding a plate.") + logger.info("Homing %s", self.name) + await self.send_command("home", timeout=self.motion_timeout) + logger.info("Homed %s", self.name) + + async def request_is_parked(self) -> bool: + """Whether the machine is genuinely safe to move: homed AND the spatula + slide/lift axes are retracted. + + 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) and + lift (Z) axes are near their home positions. + """ + if not await self.request_is_homed(): + return False + positions = await self.request_axis_positions() + y = positions.get("Y axis") + z = positions.get("Z axis") + return ( + y is not None + and z is not None + and abs(y) < self._retracted_y_max + and abs(z) < self._retracted_z_max + ) + + async def recover(self) -> bool: + """Retract the spatula and re-home after a motion fault. + + 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. + Automatic recovery is refused while the spatula plate sensor is active, + because the plate cannot be relocated safely without physical inspection. + Retries a few times. Returns ``True`` once :meth:`request_is_parked`. + """ + if await self.request_spatula_is_holding(): + raise RuntimeError( + "Cannot recover automatically while the spatula reports that it is holding a plate." + ) + + logger.warning("Starting motion recovery for %s", self.name) + for attempt in range(1, 4): + for command in ("enable", "spatulaout"): + try: + await self.send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + logger.warning( + "Recovery attempt %d: %s failed on %s: %s", + attempt, + command, + self.name, + exc, + ) + try: + await self.send_command("home", timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + logger.warning("Recovery attempt %d: home failed on %s: %s", attempt, self.name, exc) + if await self.request_is_parked(): + logger.info("Motion recovery completed for %s on attempt %d", self.name, attempt) + return True + logger.error("Motion recovery failed for %s after 3 attempts", self.name) + return False + + 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") + and the store retracted cleanly; the machine is safe to keep using. + - :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. + + 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:`request_is_homed`) to detect that case. + """ + command = f"pick {stacker} {slot} {nest}" + logger.info( + "Moving a plate in %s from stacker %d slot %d to nest %d", + self.name, + stacker, + slot, + nest, + ) + try: + await self.send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + if left_unsafe(exc.error_lines) or not await self.request_is_homed(): + logger.error("Pick left %s unsafe: %s", self.name, exc) + raise HighResSampleStorageFault(command, exc.error_lines) from exc + if any("no plate detected" in line.lower() for line in exc.error_lines): + logger.warning("No plate found in %s stacker %d slot %d", self.name, stacker, slot) + raise PlateNotFoundError(command, exc.error_lines) from exc + logger.error("Pick failed on %s: %s", self.name, 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)``. + + 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. + """ + command = f"place {stacker} {slot} {nest}" + logger.info( + "Moving a plate in %s from nest %d to stacker %d slot %d", + self.name, + nest, + stacker, + slot, + ) + try: + await self.send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + if left_unsafe(exc.error_lines) or not await self.request_is_homed(): + logger.error("Place left %s unsafe: %s", self.name, exc) + raise HighResSampleStorageFault(command, exc.error_lines) from exc + logger.error("Place failed on %s: %s", self.name, exc) + raise + if not close_door: + await self.open_all_doors() + + @staticmethod + def _robot_doors_reached( + doors: Dict[str, DoorState], acceptable_states: Tuple[DoorState, ...] + ) -> bool: + robot_doors = [state for name, state in doors.items() if name.casefold() != "user door"] + return bool(robot_doors) and all(state in acceptable_states for state in robot_doors) + + async def _wait_for_robot_doors( + self, target: Literal["open", "closed"], moving: Literal["opening", "closing"] + ) -> bool: + """Wait for every robot door to reach ``target`` after a firmware error. + + Returns ``False`` immediately if a door reports a contradictory or unknown + state, or after the configured motion timeout expires. + """ + deadline = asyncio.get_running_loop().time() + self.motion_timeout + while True: + doors = await self.request_door_status() + if self._robot_doors_reached(doors, acceptable_states=(target,)): + return True + if not self._robot_doors_reached(doors, acceptable_states=(target, moving)): + return False + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return False + await asyncio.sleep(min(0.1, remaining)) + + async def open_all_doors(self) -> None: + """Open every pneumatic robot door. + + Firmware 3.0.0.119 can return ``ERROR!`` after completing this operation. + In that case the door sensors are the authoritative postcondition; the + original command error is re-raised unless every robot door reaches open. + Transitional states are polled up to :attr:`motion_timeout`. The manual user + door is intentionally excluded. + """ + try: + logger.info("Opening robot doors on %s", self.name) + await self.send_command("openalldoors", timeout=self.motion_timeout) + except HighResSampleStorageError: + logger.warning("Open-all-doors returned an error on %s; checking door sensors", self.name) + if await self._wait_for_robot_doors(target="open", moving="opening"): + logger.info("Robot doors on %s reached open despite firmware error", self.name) + return + raise + + async def close_all_doors(self) -> None: + """Close every pneumatic robot door. + + As with :meth:`open_all_doors`, accept an erroneous completion only when + the live door report reaches the requested final state. + """ + try: + logger.info("Closing robot doors on %s", self.name) + await self.send_command("closealldoors", timeout=self.motion_timeout) + except HighResSampleStorageError: + logger.warning("Close-all-doors returned an error on %s; checking door sensors", self.name) + if await self._wait_for_robot_doors(target="closed", moving="closing"): + logger.info("Robot doors on %s reached closed despite firmware error", self.name) + return + raise + + async def clear_abort(self) -> None: + """Clear an abort state reported by the device. + + Firmware 3.0.0.119 exposes ``clearabort`` but no command for initiating an + abort, so this is recovery for device- or externally-initiated aborts. + """ + logger.info("Clearing abort state on %s", self.name) + await self.send_command("clearabort") + + # --- plate retrieval ------------------------------------------------------- + + @property + def racks(self) -> List[PlateCarrier]: + return 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!r} not found in {self.name!r}.") + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + plate_height = plate.get_size_z() + if plate.lid is not None: + lid_location = plate.get_lid_location(plate.lid) + plate_height = max(plate_height, lid_location.z + plate.lid.get_size_z()) + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= plate_height + ] + if not available: + raise NoFreeSiteError( + f"No free site at least {plate_height:g} mm high found for plate {plate.name!r}." + ) + return sorted(available, key=lambda site: site.get_size_z()) + + async def _require_nest_states(self, expected: Dict[int, NestState]) -> None: + """Require exact live sensor states before issuing a transfer command.""" + actual = await self.request_nest_status() + for nest, expected_state in expected.items(): + actual_state = actual.get(nest) + if actual_state != expected_state: + raise RuntimeError( + f"Cannot transfer plate: nest {nest} must be {expected_state}, " + f"but its sensor reports {actual_state or 'missing'}." + ) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + def _locate(self, site: PlateHolder) -> Tuple[int, int]: + if id(site) not in self._site_locations: + raise ValueError(f"Site '{site.name}' is not a known stacker slot.") + return self._site_locations[id(site)] + + def _nest_for_tray(self, tray_index: int) -> int: + """Map a 0-based tray index to a nest number reported by the device.""" + if not self._nest_numbers: + raise RuntimeError("Nests have not been loaded; call setup() first.") + if not 0 <= tray_index < len(self._nest_numbers): + raise ValueError( + f"sample store has trays 0..{len(self._nest_numbers) - 1}; got tray_index={tray_index}." + ) + return self._nest_numbers[tray_index] + + async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index: int) -> Plate: + async with self._transfer_lock: + if isinstance(plate, str): + stored_site = self.get_site_by_plate_name(plate) + stored_resource = stored_site.resource + if not isinstance(stored_resource, Plate): + raise ResourceNotFoundError(f"Plate {plate!r} not found in {self.name!r}.") + plate = stored_resource + parent = plate.parent + if not isinstance(parent, PlateHolder): + raise ValueError(f"Plate '{plate.name}' is not in a stacker slot.") + stacker, slot = self._locate(parent) + nest_number = self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + nest.check_can_drop_resource_here(plate) + await self._require_nest_states({nest_number: "clear"}) + + with event_operation( + "incubator.fetch_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(parent), + destination=resource_reference(nest), + ): + await self._pick(stacker, slot, nest_number) + + plate.unassign() + nest.assign_child_resource(plate) + return plate + + async def take_in_plate( + self, + tray_index: int, + site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest", + ) -> Plate: + async with self._transfer_lock: + self._nest_for_tray(tray_index) + plate = self.nests[tray_index].resource + if not isinstance(plate, Plate): + raise ResourceNotFoundError(f"No plate on tray {tray_index}.") + + if site == "random": + destination = self.find_random_site(plate) + elif site == "smallest": + destination = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name!r} is not available for plate {plate.name!r}.") + destination = site + else: + raise ValueError(f"Invalid site: {site!r}") + + nest = self.nests[tray_index] + with event_operation( + "incubator.take_in_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(nest), + destination=resource_reference(destination), + ): + await self._store_plate(plate, destination, tray_index) + return plate + + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: + async with self._transfer_lock: + self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + with event_operation( + "incubator.take_in_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(nest), + destination=resource_reference(site), + ): + await self._store_plate(plate, site, tray_index) + + async def transfer_plate_between_nests( + self, source_tray_index: int, destination_tray_index: int + ) -> Plate: + """Move a plate between two transfer nests. + + Tray indices are zero-based and map to the sorted nest numbers reported by + the device during :meth:`setup`. + """ + async with self._transfer_lock: + if source_tray_index == destination_tray_index: + raise ValueError("Source and destination tray indices must be different.") + source_number = self._nest_for_tray(source_tray_index) + destination_number = self._nest_for_tray(destination_tray_index) + source = self.nests[source_tray_index] + destination = self.nests[destination_tray_index] + plate = source.resource + if not isinstance(plate, Plate): + raise ResourceNotFoundError(f"No plate on tray {source_tray_index}.") + destination.check_can_drop_resource_here(plate) + await self._require_nest_states({source_number: "occupied", destination_number: "clear"}) + + with event_operation( + "incubator.transfer_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(source), + destination=resource_reference(destination), + ): + logger.info( + "Moving plate %s in %s from nest %d to nest %d", + plate.name, + self.name, + source_number, + destination_number, + ) + await self.send_command( + f"nesttransfer {source_number} {destination_number}", timeout=self.motion_timeout + ) + plate.unassign() + destination.assign_child_resource(plate) + return plate + + async def _store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: + stacker, slot = self._locate(site) + nest_number = self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + if plate.parent is not nest: + raise ValueError(f"Plate '{plate.name}' is not on tray {tray_index}.") + site.check_can_drop_resource_here(plate) + await self._require_nest_states({nest_number: "occupied"}) + + await self._place(stacker, slot, nest_number) + + plate.unassign() + site.assign_child_resource(plate) diff --git a/pylabrobot/high_res/sample_storage/driver/environment.py b/pylabrobot/high_res/sample_storage/driver/environment.py new file mode 100644 index 00000000000..3401299a8f8 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/environment.py @@ -0,0 +1,239 @@ +import logging +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from pylabrobot.events import evented_operation, resource_reference + +from .errors import HighResSampleStorageError +from .types import EnvironmentParameter + +if TYPE_CHECKING: + from .driver import HighResSampleStorage + +logger = logging.getLogger(__name__) + + +def _control_event_context(self: "EnvironmentControl") -> Dict[str, Any]: + return {"device": resource_reference(self._driver), "resources": []} + + +def _set_temperature_event_context( + self: "EnvironmentControl", temperature: float +) -> Dict[str, Any]: + return { + **_control_event_context(self), + "target_temperature": float(temperature), + "passive": False, + } + + +def _set_humidity_event_context(self: "EnvironmentControl", humidity: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_humidity": float(humidity)} + + +def _set_co2_event_context(self: "EnvironmentControl", co2: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_co2": float(co2)} + + +def _set_o2_event_context(self: "EnvironmentControl", o2: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_o2": float(o2)} + + +class EnvironmentControl: + """Temperature, humidity, and gas control for a HighRes sample store. + + Concentrations and relative humidity use fractions in the public API. The + device protocol uses percentages, so ``0.05`` CO2 is sent as ``5``. + """ + + def __init__(self, driver: "HighResSampleStorage"): + super().__init__() + self._driver = driver + self._parameters: Dict[str, EnvironmentParameter] = {} + + async def refresh(self) -> Dict[str, EnvironmentParameter]: + """Read and cache all environmental channels reported by the device.""" + self._parameters = await self._driver.request_environment() + return dict(self._parameters) + + @property + def parameters(self) -> Dict[str, EnvironmentParameter]: + """The environmental channels from the most recent read.""" + return dict(self._parameters) + + def _cached_channel_is_controllable(self, channel: str) -> Optional[bool]: + if not self._parameters: + return None + parameter = self._parameters.get(channel) + return parameter is not None and parameter.setpoint is not None + + async def _request_parameter(self, channel: str) -> EnvironmentParameter: + parameters = await self.refresh() + try: + return parameters[channel] + except KeyError as exc: + raise HighResSampleStorageError( + "environmentstatus", [f"no {channel} channel reported"] + ) from exc + + async def _request_setpoint(self, channel: str) -> float: + parameter = await self._request_parameter(channel) + if parameter.setpoint is None: + raise HighResSampleStorageError( + "environmentstatus", [f"{channel} does not report a setpoint"] + ) + return parameter.setpoint + + async def _require_controllable_channel(self, channel: str) -> EnvironmentParameter: + parameter = (await self.refresh()).get(channel) + if parameter is None or parameter.setpoint is None: + raise NotImplementedError(f"The installed device does not control {channel}.") + return parameter + + async def _set_percentage(self, channel: str, value: float) -> None: + if not 0.0 <= value <= 1.0: + raise ValueError(f"{channel} must be between 0 and 1.") + await self._require_controllable_channel(channel) + logger.info("Setting %s %s target to %g", self._driver.name, channel, value) + await self._driver.send_command(f"environmentset {channel} {value * 100:g}") + + async def _set_control_enabled(self, channel: str, enabled: bool) -> None: + await self._require_controllable_channel(channel) + action = "enable" if enabled else "disable" + logger.info("Setting %s %s control to %s", self._driver.name, channel, action) + await self._driver.send_command(f"environment {action} {channel.lower()}") + + @property + def supports_active_cooling(self) -> bool: + return self._driver.model_info.supports_active_cooling + + @property + def supports_heating(self) -> bool: + return self._driver.model_info.supports_heating + + @property + def temperature_range(self) -> Optional[Tuple[float, float]]: + return self._driver.model_info.temperature_range + + @property + def humidity_range(self) -> Optional[Tuple[float, float]]: + return self._driver.model_info.humidity_range + + async def request_current_temperature(self) -> float: + return (await self._request_parameter("TEMP")).current + + async def request_target_temperature(self) -> float: + return await self._request_setpoint("TEMP") + + @evented_operation("temperature_controller.set_temperature", _set_temperature_event_context) + async def set_temperature(self, temperature: float) -> None: + temperature_range = self.temperature_range + if temperature_range is not None: + minimum, maximum = temperature_range + if not minimum <= temperature <= maximum: + raise ValueError( + f"temperature must be between {minimum:g} and {maximum:g} C for {self._driver.model}." + ) + installed = await self._require_controllable_channel("TEMP") + if installed.limit is not None and temperature > installed.limit: + raise ValueError( + f"temperature must not exceed the installed limit of {installed.limit:g} C " + f"for {self._driver.model}." + ) + logger.info("Setting %s temperature target to %g C", self._driver.name, temperature) + await self._driver.send_command(f"environmentset TEMP {temperature}") + + @evented_operation("temperature_controller.activate", _control_event_context) + async def start_temperature_control(self) -> None: + await self._set_control_enabled("TEMP", True) + + @evented_operation("temperature_controller.deactivate", _control_event_context) + async def stop_temperature_control(self) -> None: + await self._set_control_enabled("TEMP", False) + + @property + def supports_humidity_control(self) -> bool: + installed = self._cached_channel_is_controllable("RH") + if installed is not None: + return installed + return self._driver.model_info.supports_humidity_control + + async def request_current_humidity(self) -> float: + return (await self._request_parameter("RH")).current / 100.0 + + async def request_target_humidity(self) -> float: + return await self._request_setpoint("RH") / 100.0 + + @evented_operation("humidity_controller.set_humidity", _set_humidity_event_context) + async def set_humidity(self, humidity: float) -> None: + humidity_range = self.humidity_range + if humidity_range is not None and not humidity_range[0] <= humidity <= humidity_range[1]: + raise ValueError( + f"humidity must be between {humidity_range[0]:g} and {humidity_range[1]:g} " + f"for {self._driver.model}." + ) + await self._set_percentage("RH", humidity) + + @evented_operation("humidity_controller.activate", _control_event_context) + async def start_humidity_control(self) -> None: + await self._set_control_enabled("RH", True) + + @evented_operation("humidity_controller.deactivate", _control_event_context) + async def stop_humidity_control(self) -> None: + await self._set_control_enabled("RH", False) + + @property + def supports_co2_control(self) -> bool: + installed = self._cached_channel_is_controllable("CO2") + if installed is not None: + return installed + return self._driver.model_info.supports_co2_control + + async def request_current_co2(self) -> float: + return (await self._request_parameter("CO2")).current / 100.0 + + async def request_target_co2(self) -> float: + return await self._request_setpoint("CO2") / 100.0 + + @evented_operation("co2_controller.set_co2", _set_co2_event_context) + async def set_co2(self, co2: float) -> None: + await self._set_percentage("CO2", co2) + + @evented_operation("co2_controller.activate", _control_event_context) + async def start_co2_control(self) -> None: + await self._set_control_enabled("CO2", True) + + @evented_operation("co2_controller.deactivate", _control_event_context) + async def stop_co2_control(self) -> None: + await self._set_control_enabled("CO2", False) + + @property + def supports_o2_control(self) -> bool: + installed = self._cached_channel_is_controllable("O2") + if installed is not None: + return installed + return self._driver.model_info.supports_o2_control + + async def request_current_o2(self) -> float: + return (await self._request_parameter("O2")).current / 100.0 + + async def request_target_o2(self) -> float: + return await self._request_setpoint("O2") / 100.0 + + @evented_operation("o2_controller.set_o2", _set_o2_event_context) + async def set_o2(self, o2: float) -> None: + await self._set_percentage("O2", o2) + + @evented_operation("o2_controller.activate", _control_event_context) + async def start_o2_control(self) -> None: + await self._set_control_enabled("O2", True) + + @evented_operation("o2_controller.deactivate", _control_event_context) + async def stop_o2_control(self) -> None: + await self._set_control_enabled("O2", False) + + async def request_tank_pressures(self) -> Dict[str, float]: + """Return ``TANK*`` sensor readings in the device-reported pressure unit.""" + parameters = await self.refresh() + return { + name: parameter.current for name, parameter in parameters.items() if name.startswith("TANK") + } diff --git a/pylabrobot/high_res/sample_storage/errors.py b/pylabrobot/high_res/sample_storage/driver/errors.py similarity index 78% rename from pylabrobot/high_res/sample_storage/errors.py rename to pylabrobot/high_res/sample_storage/driver/errors.py index 1966cbccd87..0c9bf086199 100644 --- a/pylabrobot/high_res/sample_storage/errors.py +++ b/pylabrobot/high_res/sample_storage/driver/errors.py @@ -1,6 +1,10 @@ from typing import List +class NoFreeSiteError(Exception): + pass + + class HighResSampleStorageError(Exception): """A command returned an ``ERROR!`` completion status. @@ -18,13 +22,23 @@ def __init__(self, command: str, error_lines: List[str]): class HighResSampleStorageAbortedError(Exception): - """A command returned an ``ABORTED!`` completion status (e.g. after ``abort``).""" + """A command returned an ``ABORTED!`` completion status.""" def __init__(self, command: str): self.command = command super().__init__(f"'{command}' was aborted") +class HighResSampleStorageProtocolError(Exception): + """The device returned a malformed or mismatched command response.""" + + def __init__(self, command: str, response: str, detail: str): + self.command = command + self.response = response + self.detail = detail + super().__init__(f"Invalid response to {command!r}: {detail}; received {response!r}") + + class PlateNotFoundError(HighResSampleStorageError): """A pick found no plate in the target slot ("No plate detected"). @@ -41,8 +55,8 @@ class HighResSampleStorageFault(HighResSampleStorageError): The canonical trigger is picking an empty *top* slot. The machine is not usable until recovered — call - :meth:`HighResSampleStorageAutomatedRetrievalBackend.recover` (retract the - spatula and re-home) before issuing further motion. + :meth:`HighResSampleStorage.recover` (retract the spatula and re-home) before + issuing further motion. """ def __init__(self, command: str, error_lines: List[str]): @@ -64,7 +78,6 @@ 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:`HighResSampleStorageAutomatedRetrievalBackend.recover`.""" + extended / unhomed), requiring :meth:`HighResSampleStorage.recover`.""" blob = " ".join(error_lines).lower() return any(sig in blob for sig in _UNSAFE_SIGNATURES) diff --git a/pylabrobot/high_res/sample_storage/driver/humidity.py b/pylabrobot/high_res/sample_storage/driver/humidity.py deleted file mode 100644 index 45d176a1f2e..00000000000 --- a/pylabrobot/high_res/sample_storage/driver/humidity.py +++ /dev/null @@ -1,29 +0,0 @@ -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/high_res/sample_storage/driver/models.py b/pylabrobot/high_res/sample_storage/driver/models.py new file mode 100644 index 00000000000..15b333c8dad --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/models.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + + +@dataclass(frozen=True) +class ModelInfo: + has_environment_control: bool + temperature_range: Optional[Tuple[float, float]] + humidity_range: Optional[Tuple[float, float]] + supports_heating: bool + supports_active_cooling: bool + supports_humidity_control: bool + supports_co2_control: bool + supports_o2_control: bool + + +_STERISTORE_INFO = ModelInfo( + has_environment_control=True, + temperature_range=(4.0, 100.0), + humidity_range=(0.0, 0.98), + supports_heating=True, + supports_active_cooling=True, + supports_humidity_control=True, + supports_co2_control=True, + # O2 regulation is optional, so it is discovered from environmentstatus. + supports_o2_control=False, +) + + +_MODEL_INFO: Dict[str, ModelInfo] = { + "HighResSampleStorage": ModelInfo( + has_environment_control=True, + temperature_range=None, + humidity_range=None, + supports_heating=False, + supports_active_cooling=False, + supports_humidity_control=False, + supports_co2_control=False, + supports_o2_control=False, + ), + "AmbiStore": ModelInfo( + has_environment_control=False, + temperature_range=None, + humidity_range=None, + supports_heating=False, + supports_active_cooling=False, + supports_humidity_control=False, + supports_co2_control=False, + supports_o2_control=False, + ), + "SteriStore": _STERISTORE_INFO, + "SteriStore2": _STERISTORE_INFO, + "TundraStore": ModelInfo( + has_environment_control=True, + temperature_range=(-20.0, 4.0), + # The supported RH range depends on the configured temperature. + humidity_range=None, + supports_heating=False, + supports_active_cooling=True, + supports_humidity_control=True, + supports_co2_control=False, + supports_o2_control=False, + ), +} + + +def get_model_info(model_name: str) -> ModelInfo: + """Return the known values for a user-configured sample-store model.""" + try: + return _MODEL_INFO[model_name] + except KeyError as exc: + raise ValueError(f"Unknown HighRes sample store model: {model_name!r}") from exc diff --git a/pylabrobot/high_res/sample_storage/driver/protocol.py b/pylabrobot/high_res/sample_storage/driver/protocol.py index a55bb07e270..89bf134b9f0 100644 --- a/pylabrobot/high_res/sample_storage/driver/protocol.py +++ b/pylabrobot/high_res/sample_storage/driver/protocol.py @@ -1,5 +1,4 @@ -"""Low-level wire-protocol constants and helpers shared by the driver and the -per-capability backends.""" +"""Low-level wire-protocol constants and parsing helpers.""" from typing import Dict, List diff --git a/pylabrobot/high_res/sample_storage/driver/settings.py b/pylabrobot/high_res/sample_storage/driver/settings.py new file mode 100644 index 00000000000..86ac921ed9a --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/settings.py @@ -0,0 +1,710 @@ +"""Typed, immutable view of a HighRes sample store'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:`HighResSampleStorageSettings` is loaded whole from the +device (or a capture) and is frozen once built. Firmware/model-specific keys are +preserved in :attr:`HighResSampleStorageSettings.raw`; known keys that a device +does not report remain ``None``. +""" + +import logging +from dataclasses import dataclass, field, fields +from types import MappingProxyType +from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, get_args + +logger = logging.getLogger(__name__) + + +# Known family names reported by the device's MACHINE_TYPE setting. The wire +# value remains a string because firmware variants may introduce additional +# model names without requiring a driver release just to parse their settings. +MachineType = str +KNOWN_MACHINE_TYPES: Tuple[str, ...] = ( + "AmbiStore", + "SteriStore", + "SteriStore2", + "TundraStore", +) + + +@dataclass(frozen=True) +class HighResSampleStorageSettings: + """Known on-device settings plus the complete raw key/value response.""" + + product_name: Optional[str] = None + product_description: Optional[str] = None + + serial_number: Optional[str] = None + + machine_type: Optional[MachineType] = None + + rest_server_port: Optional[int] = None + + syslog_server: Optional[str] = None + syslog_level: Optional[int] = None + + internal_log_level: Optional[int] = None + + carousel_home_speed_fast: Optional[float] = None + carousel_home_speed_slow: Optional[float] = None + carousel_home_acceleration: Optional[float] = None + carousel_velocity: Optional[float] = None + carousel_idle_velocity: Optional[float] = None + carousel_acceleration: Optional[float] = None + carousel_abort_deceleration: Optional[float] = None + carousel_jerk: Optional[float] = None + carousel_final_drive_jerk: Optional[float] = None + carousel_stacker_0_pos: Optional[float] = None + carousel_stacker_1_pos: Optional[float] = None + carousel_stacker_2_pos: Optional[float] = None + carousel_stacker_count: Optional[int] = None + carousel_count: Optional[int] = None + carousel_calibration_offset: Optional[float] = None + + spatula_home_speed_fast: Optional[float] = None + spatula_home_speed_slow: Optional[float] = None + spatula_home_acceleration: Optional[float] = None + spatula_velocity: Optional[float] = None + spatula_velocity_with_plate: Optional[float] = None + spatula_acceleration: Optional[float] = None + spatula_abort_deceleration: Optional[float] = None + spatula_jerk: Optional[float] = None + spatula_rot_home_speed_fast: Optional[float] = None + spatula_rot_home_speed_slow: Optional[float] = None + spatula_rot_home_acceleration: Optional[float] = None + spatula_rot_velocity: Optional[float] = None + spatula_rot_acceleration: Optional[float] = None + spatula_rot_abort_deceleration: Optional[float] = None + spatula_rot_jerk: Optional[float] = None + spatula_rot_zero_pos: Optional[float] = None + spatula_rot_stack_pos_0: Optional[float] = None + spatula_rot_stack_pos_1: Optional[float] = None + spatula_rot_stack_pos_2: Optional[float] = None + spatula_rot_nest_1_pos: Optional[float] = None + spatula_rot_nest_2_pos: Optional[float] = None + spatula_rot_nest_3_pos: Optional[float] = None + spatula_rot_nest_4_pos: Optional[float] = None + spatula_rot_nest_5_pos: Optional[float] = None + spatula_rot_nest_6_pos: Optional[float] = None + spatula_rot_nest_7_pos: Optional[float] = None + spatula_rot_nest_8_pos: Optional[float] = None + spatula_rot_nest_9_pos: Optional[float] = None + spatula_rot_nest_10_pos: Optional[float] = None + spatula_rot_nest_21_pos: Optional[float] = None + spatula_rot_nest_22_pos: Optional[float] = None + spatula_rot_nest_23_pos: Optional[float] = None + spatula_rot_nest_24_pos: Optional[float] = None + spatula_rot_nest_51_pos: Optional[float] = None + spatula_rot_nest_52_pos: Optional[float] = None + spatula_rot_nest_61_pos: Optional[float] = None + spatula_rot_nest_62_pos: Optional[float] = None + spatula_rot_nest_63_pos: Optional[float] = None + spatula_rot_nest_64_pos: Optional[float] = None + spatula_rot_nest_65_pos: Optional[float] = None + spatula_rot_nest_66_pos: Optional[float] = None + spatula_rot_nest_67_pos: Optional[float] = None + spatula_rot_nest_68_pos: Optional[float] = None + spatula_rot_nest_69_pos: Optional[float] = None + spatula_slide_home_speed_fast: Optional[float] = None + spatula_slide_home_speed_slow: Optional[float] = None + spatula_slide_home_acceleration: Optional[float] = None + spatula_slide_home_offset: Optional[float] = None + spatula_slide_velocity: Optional[float] = None + spatula_slide_acceleration: Optional[float] = None + spatula_slide_abort_deceleration: Optional[float] = None + spatula_slide_jerk: Optional[float] = None + spatula_slide_in_pos_0: Optional[float] = None + spatula_slide_in_pos_1: Optional[float] = None + spatula_slide_in_pos_2: Optional[float] = None + spatula_slide_nest_1_pos: Optional[float] = None + spatula_slide_nest_2_pos: Optional[float] = None + spatula_slide_nest_3_pos: Optional[float] = None + spatula_slide_nest_4_pos: Optional[float] = None + spatula_slide_nest_5_pos: Optional[float] = None + spatula_slide_nest_6_pos: Optional[float] = None + spatula_slide_nest_7_pos: Optional[float] = None + spatula_slide_nest_8_pos: Optional[float] = None + spatula_slide_nest_9_pos: Optional[float] = None + spatula_slide_nest_10_pos: Optional[float] = None + spatula_slide_nest_21_pos: Optional[float] = None + spatula_slide_nest_22_pos: Optional[float] = None + spatula_slide_nest_23_pos: Optional[float] = None + spatula_slide_nest_24_pos: Optional[float] = None + spatula_slide_nest_51_pos: Optional[float] = None + spatula_slide_nest_52_pos: Optional[float] = None + spatula_slide_nest_61_pos: Optional[float] = None + spatula_slide_nest_62_pos: Optional[float] = None + spatula_slide_nest_63_pos: Optional[float] = None + spatula_slide_nest_64_pos: Optional[float] = None + spatula_slide_nest_65_pos: Optional[float] = None + spatula_slide_nest_66_pos: Optional[float] = None + spatula_slide_nest_67_pos: Optional[float] = None + spatula_slide_nest_68_pos: Optional[float] = None + spatula_slide_nest_69_pos: Optional[float] = None + spatula_valve_hold: Optional[int] = None + spatula_plate_sensor: Optional[int] = None + spatula_plate_release_sensor: Optional[int] = None + + inner_user_door_sensor: Optional[int] = None + + door_open_sensor_output: Optional[int] = None + + nest_count: Optional[int] = None + nest_1_height: Optional[float] = None + nest_2_height: Optional[float] = None + nest_3_height: Optional[float] = None + nest_4_height: Optional[float] = None + nest_5_height: Optional[float] = None + nest_6_height: Optional[float] = None + nest_7_height: Optional[float] = None + nest_8_height: Optional[float] = None + nest_9_height: Optional[float] = None + nest_10_height: Optional[float] = None + nest_21_height: Optional[float] = None + nest_22_height: Optional[float] = None + nest_23_height: Optional[float] = None + nest_24_height: Optional[float] = None + nest_51_height: Optional[float] = None + nest_52_height: Optional[float] = None + nest_61_height: Optional[float] = None + nest_62_height: Optional[float] = None + nest_63_height: Optional[float] = None + nest_64_height: Optional[float] = None + nest_65_height: Optional[float] = None + nest_66_height: Optional[float] = None + nest_67_height: Optional[float] = None + nest_68_height: Optional[float] = None + nest_69_height: Optional[float] = None + nest_1_style: Optional[str] = None + nest_2_style: Optional[str] = None + nest_3_style: Optional[str] = None + nest_4_style: Optional[str] = None + nest_5_style: Optional[str] = None + nest_6_style: Optional[str] = None + nest_7_style: Optional[str] = None + nest_8_style: Optional[str] = None + nest_9_style: Optional[str] = None + nest_10_style: Optional[str] = None + nest_clearance_above: Optional[float] = None + nest_clearance_below: Optional[float] = None + + handover_nest_clearance_above: Optional[float] = None + handover_nest_clearance_below: Optional[float] = None + handover_y_rotation_position: Optional[float] = None + + conveyor_clearance_above: Optional[float] = None + conveyor_clearance_below: Optional[float] = None + + static_nest_clearance_above: Optional[float] = None + static_nest_clearance_below: Optional[float] = None + + io_nest_clearance_above: Optional[float] = None + io_nest_clearance_below: Optional[float] = None + + nest_1_sense_input: Optional[int] = None + nest_2_sense_input: Optional[int] = None + nest_3_sense_input: Optional[int] = None + nest_4_sense_input: Optional[int] = None + nest_5_sense_input: Optional[int] = None + nest_6_sense_input: Optional[int] = None + nest_7_sense_input: Optional[int] = None + nest_8_sense_input: Optional[int] = None + nest_9_sense_input: Optional[int] = None + nest_10_sense_input: Optional[int] = None + + stacker_base_0: Optional[float] = None + stacker_base_1: Optional[float] = None + stacker_base_2: Optional[float] = None + + barcode_base_0: Optional[float] = None + barcode_base_1: Optional[float] = None + + stacker_clearance_above: Optional[float] = None + stacker_clearance_below: Optional[float] = None + + barcode_scanner: Optional[str] = None + barcode_laser_start: Optional[int] = None + barcode_laser_stop: Optional[int] = None + barcode_velocity: Optional[float] = None + barcode_acceleration: Optional[float] = None + barcode_config_itf_enable: Optional[str] = None + barcode_config_itf_status: Optional[str] = None + barcode_config_itf_length_1: Optional[int] = None + barcode_config_itf_length_2: Optional[int] = None + barcode_config_itf_range: Optional[str] = None + + plate_hold_settle_time: Optional[int] = None + + door_0_position: Optional[float] = None + door_height: Optional[float] = None + door_overlap_negative: Optional[float] = None + door_overlap_positive: Optional[float] = None + door_gasket_valve: Optional[int] = None + + big_door_valve: Optional[int] = None + + door_1_valve: Optional[int] = None + door_2_valve: Optional[int] = None + door_3_valve: Optional[int] = None + door_4_valve: Optional[int] = None + door_5_valve: Optional[int] = None + door_6_valve: Optional[int] = None + door_7_valve: Optional[int] = None + door_8_valve: Optional[int] = None + door_1_open_sensor: Optional[int] = None + door_2_open_sensor: Optional[int] = None + door_3_open_sensor: Optional[int] = None + door_4_open_sensor: Optional[int] = None + door_5_open_sensor: Optional[int] = None + door_6_open_sensor: Optional[int] = None + door_7_open_sensor: Optional[int] = None + door_8_open_sensor: Optional[int] = None + door_1_close_sensor: Optional[int] = None + door_2_close_sensor: Optional[int] = None + door_3_close_sensor: Optional[int] = None + door_4_close_sensor: Optional[int] = None + door_5_close_sensor: Optional[int] = None + door_6_close_sensor: Optional[int] = None + door_7_close_sensor: Optional[int] = None + door_8_close_sensor: Optional[int] = None + door_ri_open_sensor: Optional[int] = None + door_ri_close_sensor: Optional[int] = None + + gasket_deflate_delay_ms: Optional[int] = None + gasket_inflate_delay_ms: Optional[int] = None + + big_door_open_delay_ms: Optional[int] = None + big_door_close_delay_ms: Optional[int] = None + + door_open_delay_ms: Optional[int] = None + door_close_delay_ms: Optional[int] = None + door_open_signal_active_level: Optional[int] = None + + vac_1_enable: Optional[int] = None + vac_2_enable: Optional[int] = None + vac_1_purge: Optional[int] = None + vac_2_purge: Optional[int] = None + + lift_1_enable: Optional[int] = None + lift_2_enable: Optional[int] = None + + nest_1_sense: Optional[int] = None + nest_2_sense: Optional[int] = None + + vac_3_enable: Optional[int] = None + vac_4_enable: Optional[int] = None + vac_3_purge: Optional[int] = None + vac_4_purge: Optional[int] = None + + lift_3_enable: Optional[int] = None + lift_4_enable: Optional[int] = None + + nest_3_sense: Optional[int] = None + nest_4_sense: Optional[int] = None + + vac_5_enable: Optional[int] = None + vac_6_enable: Optional[int] = None + vac_5_purge: Optional[int] = None + vac_6_purge: Optional[int] = None + + lift_5_enable: Optional[int] = None + lift_6_enable: Optional[int] = None + + nest_5_sense: Optional[int] = None + nest_6_sense: Optional[int] = None + + vac_7_enable: Optional[int] = None + vac_8_enable: Optional[int] = None + vac_7_purge: Optional[int] = None + vac_8_purge: Optional[int] = None + + lift_7_enable: Optional[int] = None + lift_8_enable: Optional[int] = None + + nest_7_sense: Optional[int] = None + nest_8_sense: Optional[int] = None + + active_hotels: Optional[int] = None + + nest_rot_home_speed_fast: Optional[int] = None + nest_rot_home_speed_slow: Optional[int] = None + nest_rot_home_acceleration: Optional[int] = None + nest_rot_velocity: Optional[int] = None + nest_rot_acceleration: Optional[int] = None + nest_rot_abort_deceleration: Optional[int] = None + nest_rot_jerk: Optional[int] = None + nest_rot_zero_pos: Optional[float] = None + + microspin_door_closed: Optional[float] = None + microspin_door_open: Optional[float] = None + microspin_spindle_home_offset: Optional[float] = None + microspin_bucket_radius_m: Optional[float] = None + microspin_spindle_counts_per_rev: Optional[int] = None + microspin_spindle_position_window: Optional[int] = None + microspin_door_velocity: Optional[int] = None + microspin_door_home_velocity: Optional[int] = None + microspin_door_accel: Optional[int] = None + microspin_door_abort_decel: Optional[int] = None + microspin_door_jerk: Optional[int] = None + microspin_spindle_home_velocity: Optional[int] = None + microspin_spindle_velocity: Optional[int] = None + microspin_spindle_accel: Optional[float] = None + microspin_spindle_decel: Optional[float] = None + microspin_spindle_slow_accel: Optional[float] = None + microspin_spindle_slow_decel: Optional[float] = None + microspin_spindle_abort_decel: Optional[float] = None + microspin_spindle_jerk: Optional[int] = None + microspin_spindle_max_accel: Optional[float] = None + microspin_spindle_max_decel: Optional[float] = None + microspin_idle_spindle_threshold: Optional[float] = None + microspin_bucket_rise_rpm: Optional[int] = None + + pico_rot_home_speed_fast: Optional[int] = None + pico_rot_home_speed_slow: Optional[int] = None + pico_rot_home_acceleration: Optional[int] = None + pico_rot_velocity: Optional[int] = None + pico_rot_acceleration: Optional[int] = None + pico_rot_abort_deceleration: Optional[int] = None + pico_rot_jerk: Optional[int] = None + pico_rot_zero_pos: Optional[int] = None + pico_stacker_count: Optional[int] = None + + def_plate_height: Optional[float] = None + def_stack_height: Optional[float] = None + def_plate_thickness: Optional[float] = None + + jiggle_count: Optional[int] = None + jiggle_size: Optional[int] = None + + has_lock_sensor: Optional[str] = None + + lock_sensor_input: Optional[int] = None + + carousel_max_position: Optional[int] = None + carousel_max_velocity: Optional[int] = None + carousel_max_acceleration: Optional[int] = None + carousel_max_deceleration: Optional[int] = None + carousel_max_jerk: Optional[int] = None + carousel_home_pos_offset: Optional[int] = None + carousel_home_neg_offset: Optional[float] = None + carousel_homing_speed: Optional[int] = None + carousel_home_fast: Optional[int] = None + carousel_home_slow: Optional[int] = None + carousel_home_accel: Optional[int] = None + carousel_stacker_width_default: Optional[float] = None + carousel_small_flag_width: Optional[float] = None + carousel_large_flag_check_distance: Optional[float] = None + carousel_large_flag_width: Optional[float] = None + carousel_ring_numbering: Optional[str] = None + + effectuator_extended_position: Optional[float] = None + effectuator_max_position: Optional[float] = None + effectuator_lock_position: Optional[float] = None + effectuator_unlock_position: Optional[int] = None + effectuator_max_velocity: Optional[int] = None + effectuator_max_acceleration: Optional[int] = None + effectuator_abort_deceleration: Optional[int] = None + effectuator_max_jerk: Optional[int] = None + effectuator_home_offset: Optional[int] = None + effectuator_home_fast: Optional[int] = None + effectuator_home_slow: Optional[int] = None + effectuator_home_accel: Optional[int] = None + + spatula_max_position: Optional[float] = None + spatula_max_velocity: Optional[int] = None + spatula_measure_velocity: Optional[int] = None + spatula_max_acceleration: Optional[int] = None + spatula_max_jerk: Optional[int] = None + spatula_home_offset: Optional[int] = None + spatula_home_fast: Optional[int] = None + spatula_home_slow: Optional[int] = None + spatula_home_accel: Optional[int] = None + spatula_nest_offset: Optional[float] = None + spatula_measurement_tolerance: Optional[float] = None + spatula_beam_break_height: Optional[float] = None + + max_transparency_width_um: Optional[int] = None + + spatula_lock_position: Optional[float] = None + spatula_base_position: Optional[float] = None + + barcode_max_position: Optional[float] = None + barcode_max_velocity: Optional[int] = None + barcode_max_move_velocity: Optional[int] = None + barcode_abort_deceleration: Optional[int] = None + barcode_max_move_jerk: Optional[int] = None + barcode_max_read_velocity: Optional[int] = None + barcode_home_offset: Optional[int] = None + barcode_home_fast: Optional[int] = None + barcode_home_slow: Optional[int] = None + barcode_home_accel: Optional[int] = None + + spatula_slide_safe_position: Optional[float] = None + spatula_slide_barcode_position: Optional[float] = None + + nest_safe_rotation_clearance: Optional[float] = None + + limit_x_min: Optional[float] = None + limit_x_max: Optional[float] = None + limit_y_min: Optional[float] = None + limit_y_max: Optional[float] = None + limit_z_min: Optional[float] = None + limit_z_max: Optional[float] = None + limit_theta_min: Optional[float] = None + limit_theta_max: Optional[float] = None + limit_g_min: Optional[float] = None + limit_g_max: Optional[float] = None + limit_barcode_min: Optional[float] = None + limit_barcode_max: Optional[float] = None + + tundra_door_cycle_active: Optional[str] = None + tundra_door_cycle_time_sec: Optional[int] = None + tundra_door_cycle_open_time_sec: Optional[int] = None + + barcode_height_adjust: Optional[float] = None + + tundra_outer_door_cycle_active: Optional[str] = None + tundra_outer_door_cycle_time_sec: Optional[int] = None + tundra_outer_door_cycle_open_time_sec: Optional[int] = None + + spatula_door_clearance_below: Optional[float] = None + spatula_door_clearance_above: Optional[float] = None + + weigh_cell_door_output: Optional[int] = None + weigh_cell_door_input_status_0: Optional[int] = None + weigh_cell_door_input_status_1: Optional[int] = None + weigh_cell_door_input_status_2: Optional[int] = None + weigh_cell_led_red_output: Optional[int] = None + weigh_cell_led_green_output: Optional[int] = None + weigh_cell_led_blue_output: Optional[int] = None + + axis_x_home_speed_fast: Optional[float] = None + axis_x_home_speed_slow: Optional[float] = None + axis_x_home_acceleration: Optional[float] = None + axis_x_home_offset: Optional[float] = None + axis_x2_home_offset: Optional[float] = None + + tray_to_gantry_0_distance: Optional[float] = None + + axis_x2_calibration_adjustment: Optional[float] = None + axis_x_calibration_pos: Optional[float] = None + axis_x_velocity: Optional[float] = None + axis_x_acceleration: Optional[float] = None + axis_x_abort_deceleration: Optional[float] = None + axis_x_jerk: Optional[float] = None + axis_z_home_speed_fast: Optional[float] = None + axis_z_home_speed_slow: Optional[float] = None + axis_z_home_acceleration: Optional[float] = None + axis_z_home_offset: Optional[float] = None + axis_z_home_offset_hardstop: Optional[float] = None + axis_z_home_hardstop_current_ma: Optional[int] = None + axis_z_home_hardstop_current_time_ms: Optional[int] = None + axis_z_velocity: Optional[float] = None + axis_z_acceleration: Optional[float] = None + axis_z_abort_deceleration: Optional[float] = None + axis_z_jerk: Optional[float] = None + axis_barcode_home_speed_fast: Optional[float] = None + axis_barcode_home_speed_slow: Optional[float] = None + axis_barcode_home_acceleration: Optional[float] = None + axis_barcode_home_offset: Optional[float] = None + axis_barcode_velocity: Optional[float] = None + axis_barcode_acceleration: Optional[float] = None + axis_barcode_abort_deceleration: Optional[float] = None + axis_barcode_jerk: Optional[float] = None + axis_gripper_home_speed_fast: Optional[float] = None + axis_gripper_home_speed_slow: Optional[float] = None + axis_gripper_home_acceleration: Optional[float] = None + axis_gripper_home_offset: Optional[float] = None + axis_gripper_home_offset_hardstop: Optional[float] = None + axis_gripper_home_hardstop_current_ma: Optional[int] = None + axis_gripper_home_hardstop_current_time_ms: Optional[int] = None + axis_gripper_close_position: Optional[float] = None + axis_gripper_velocity: Optional[float] = None + axis_gripper_acceleration: Optional[float] = None + axis_gripper_abort_deceleration: Optional[float] = None + axis_gripper_jerk: Optional[float] = None + + height_detect_base: Optional[float] = None + height_detect_positive_adjustment: Optional[float] = None + height_detect_negative_adjustment: Optional[float] = None + height_detect_enable_address: Optional[int] = None + + barcode_input_number: Optional[int] = None + + height_detect_input_number: Optional[int] = None + + stacker_code_clearance_above: Optional[float] = None + stacker_code_clearance_below: Optional[float] = None + stacker_code_height: Optional[float] = None + + barcode_fixture_height: Optional[float] = None + barcode_fixture_groove_height: Optional[float] = None + barcode_ideal_stacker_tier_1: Optional[float] = None + barcode_ideal_stacker_tier_2: Optional[float] = None + + muting_bank_input_1: Optional[int] = None + muting_bank_input_2: Optional[int] = None + muting_bank_input_3: Optional[int] = None + muting_input_1: Optional[int] = None + muting_input_2: Optional[int] = None + + tool_head_sel_0: Optional[int] = None + tool_head_sel_1: Optional[int] = None + tool_head_addr_0: Optional[int] = None + tool_head_addr_1: Optional[int] = None + tool_head_addr_2: Optional[int] = None + tool_head_addr_3: Optional[int] = None + + ion_bar_air_output: Optional[int] = None + ion_bar_power_output: Optional[int] = None + + busybox_mode: Optional[str] = None + + microserve_bus_voltage_threshold: Optional[float] = None + microserve_recover_after_estop: Optional[str] = None + + randomserve_bus_voltage_threshold: Optional[float] = None + randomserve_recover_after_estop: Optional[str] = None + + psp_packet_delay: Optional[int] = None + + microspin_spindle_voltage_delay: Optional[int] = None + microspin_bus_voltage_threshold: Optional[float] = None + + home_trays_to_hardstop: Optional[str] = None + + dc_out_1_default_on: Optional[str] = None + dc_out_2_default_on: Optional[str] = None + dc_out_3_default_on: Optional[str] = None + + lid_discard_drop_wait_time_ms: Optional[int] = None + + lidvalet_plate_dropped_threshold: Optional[int] = None + lidvalet_hold_time_after_unlid: Optional[int] = None + lidvalet_purge_time_ms: Optional[int] = None + lidvalet_drop_down_time_ms: Optional[int] = None + lidvalet_drop_up_time_ms: Optional[int] = None + lidvalet_pickup_wait_ms: Optional[int] = None + + disable_blink_function: Optional[str] = None + + oled_blink_time_ms: Optional[int] = None + + suppress_copley_debug_statements: Optional[str] = None + + prime_waste_chute_installed: Optional[str] = None + prime_waste_chute_position: Optional[str] = None + + plate_sensor_high_is_plate_present: Optional[str] = None + + stacker_1_speed_multiplier: Optional[float] = None + stacker_2_speed_multiplier: Optional[float] = None + stacker_3_speed_multiplier: Optional[float] = None + stacker_4_speed_multiplier: Optional[float] = None + stacker_5_speed_multiplier: Optional[float] = None + stacker_6_speed_multiplier: Optional[float] = None + stacker_7_speed_multiplier: Optional[float] = None + stacker_8_speed_multiplier: Optional[float] = None + stacker_9_speed_multiplier: Optional[float] = None + stacker_10_speed_multiplier: Optional[float] = None + stacker_11_speed_multiplier: Optional[float] = None + stacker_12_speed_multiplier: Optional[float] = None + stacker_13_speed_multiplier: Optional[float] = None + stacker_14_speed_multiplier: Optional[float] = None + stacker_15_speed_multiplier: Optional[float] = None + stacker_16_speed_multiplier: Optional[float] = None + stacker_17_speed_multiplier: Optional[float] = None + stacker_18_speed_multiplier: Optional[float] = None + stacker_19_speed_multiplier: Optional[float] = None + stacker_20_speed_multiplier: Optional[float] = None + stacker_21_speed_multiplier: Optional[float] = None + stacker_22_speed_multiplier: Optional[float] = None + stacker_23_speed_multiplier: Optional[float] = None + stacker_24_speed_multiplier: Optional[float] = None + stacker_25_speed_multiplier: Optional[float] = None + stacker_26_speed_multiplier: Optional[float] = None + stacker_27_speed_multiplier: Optional[float] = None + stacker_28_speed_multiplier: Optional[float] = None + stacker_1_clearance_above_offset: Optional[float] = None + stacker_2_clearance_above_offset: Optional[float] = None + stacker_3_clearance_above_offset: Optional[float] = None + stacker_4_clearance_above_offset: Optional[float] = None + stacker_5_clearance_above_offset: Optional[float] = None + stacker_6_clearance_above_offset: Optional[float] = None + stacker_7_clearance_above_offset: Optional[float] = None + stacker_8_clearance_above_offset: Optional[float] = None + stacker_9_clearance_above_offset: Optional[float] = None + stacker_10_clearance_above_offset: Optional[float] = None + stacker_11_clearance_above_offset: Optional[float] = None + stacker_12_clearance_above_offset: Optional[float] = None + stacker_13_clearance_above_offset: Optional[float] = None + stacker_14_clearance_above_offset: Optional[float] = None + stacker_15_clearance_above_offset: Optional[float] = None + stacker_16_clearance_above_offset: Optional[float] = None + stacker_17_clearance_above_offset: Optional[float] = None + stacker_18_clearance_above_offset: Optional[float] = None + stacker_19_clearance_above_offset: Optional[float] = None + stacker_20_clearance_above_offset: Optional[float] = None + stacker_21_clearance_above_offset: Optional[float] = None + stacker_22_clearance_above_offset: Optional[float] = None + stacker_23_clearance_above_offset: Optional[float] = None + stacker_24_clearance_above_offset: Optional[float] = None + stacker_25_clearance_above_offset: Optional[float] = None + stacker_26_clearance_above_offset: Optional[float] = None + stacker_27_clearance_above_offset: Optional[float] = None + stacker_28_clearance_above_offset: Optional[float] = None + + mfg_door_time_low_limit_ms: Optional[int] = None + mfg_door_time_high_limit_ms: Optional[int] = None + + automation_door_time_ms: Optional[int] = None + + carousel_home_adj_low_limit: Optional[int] = None + carousel_home_adj_high_limit: Optional[int] = None + + store_calibration_fixture_y_distance: Optional[float] = None + store_y_teach_minimum: Optional[float] = None + store_y_teach_maximum: Optional[float] = None + + lidvalet_wait_for_lift_rise_ms: Optional[int] = None + + raw: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}), repr=False) + + @property + def extra(self) -> Dict[str, str]: + """Firmware keys that do not have a typed attribute in this version.""" + known = {f.name.upper() for f in fields(self) if f.name != "raw"} + return {key: value for key, value in self.raw.items() if key not in known} + + @classmethod + 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: + if "=" in line: + key, _, value = line.partition("=") + data[key.strip().upper()] = value.strip() + + values: Dict[str, Any] = {"raw": MappingProxyType(dict(data))} + for f in fields(cls): + if f.name == "raw": + continue + key = f.name.upper() + if key not in data: + continue + value_type = next((arg for arg in get_args(f.type) if arg is not type(None)), str) + if value_type is int: + values[f.name] = int(data[key]) + elif value_type is float: + values[f.name] = float(data[key]) + else: + values[f.name] = data[key] + machine_type = values.get("machine_type") + if machine_type is not None and machine_type not in KNOWN_MACHINE_TYPES: + logger.warning( + "Unknown HighRes sample-store model %r; preserving all settings as raw values", + machine_type, + ) + return cls(**values) diff --git a/pylabrobot/high_res/sample_storage/driver/temperature.py b/pylabrobot/high_res/sample_storage/driver/temperature.py deleted file mode 100644 index c82b9a6b75f..00000000000 --- a/pylabrobot/high_res/sample_storage/driver/temperature.py +++ /dev/null @@ -1,32 +0,0 @@ -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/high_res/sample_storage/types.py b/pylabrobot/high_res/sample_storage/driver/types.py similarity index 100% rename from pylabrobot/high_res/sample_storage/types.py rename to pylabrobot/high_res/sample_storage/driver/types.py diff --git a/pylabrobot/high_res/sample_storage/sample_storage.py b/pylabrobot/high_res/sample_storage/sample_storage.py deleted file mode 100644 index 249a324067b..00000000000 --- a/pylabrobot/high_res/sample_storage/sample_storage.py +++ /dev/null @@ -1,141 +0,0 @@ -import warnings -from typing import List, Optional - -from pylabrobot.capabilities.automated_retrieval import RandomAccessRetrieval -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, - PlateCarrier, - PlateHolder, - Rotation, -) -from pylabrobot.resources.resource import Resource - -from .driver import HighResSampleStorageDriver - - -class _HighResSampleStorage(Resource, Device): - """Base device for HighRes Biosolutions sample stores. - - 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:`RandomAccessRetrieval` 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, - driver: HighResSampleStorageDriver, - racks: List[PlateCarrier], - nest_locations: List[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] = None, - ): - """ - 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``. - """ - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model or self._model_name, - ) - Device.__init__(self, driver=driver) - self.driver: HighResSampleStorageDriver = driver - - 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: - self.assign_child_resource(rack, location=None) - - self.retrieval = RandomAccessRetrieval( - backend=driver.automated_retrieval, racks=self._racks, loading_trays=self.nests - ) - 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]: - return self._racks - - async def setup(self, backend_params: Optional[BackendParams] = None): - await super().setup(backend_params=backend_params) - await self.driver.automated_retrieval.set_racks(self._racks) - - def serialize(self) -> dict: - from pylabrobot.serializer import serialize - - return { - **Device.serialize(self), - **Resource.serialize(self), - "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/high_res/sample_storage/settings.py b/pylabrobot/high_res/sample_storage/settings.py deleted file mode 100644 index 6d3ba32d5ef..00000000000 --- a/pylabrobot/high_res/sample_storage/settings.py +++ /dev/null @@ -1,697 +0,0 @@ -"""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:`HighResSampleStorageSettings` is loaded whole from the -device (or a capture) and is frozen once built. -""" - -import warnings -from dataclasses import dataclass, fields -from typing import Dict, Iterable, 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",) - - -@dataclass(frozen=True) -class HighResSampleStorageSettings: - """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 - - vac_7_enable: int - vac_8_enable: int - vac_7_purge: int - vac_8_purge: int - - lift_7_enable: int - lift_8_enable: int - - nest_7_sense: int - nest_8_sense: int - - active_hotels: int - - 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 - - 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 - - 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 - - def_plate_height: float - def_stack_height: float - def_plate_thickness: float - - jiggle_count: int - jiggle_size: int - - has_lock_sensor: str - - lock_sensor_input: int - - 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 - - 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 - - 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 - - max_transparency_width_um: int - - spatula_lock_position: float - spatula_base_position: float - - 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 - - spatula_slide_safe_position: float - spatula_slide_barcode_position: float - - 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_lines(cls, lines: Iterable[str]) -> "HighResSampleStorageSettings": - """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): - key = f.name.upper() - if key not in data: - missing.append(key) - continue - 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): " - + ", ".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) diff --git a/pylabrobot/high_res/sample_storage/steri_store.py b/pylabrobot/high_res/sample_storage/steri_store.py new file mode 100644 index 00000000000..95e6a7aea52 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/steri_store.py @@ -0,0 +1,7 @@ +from .driver import HighResSampleStorage + + +class SteriStore(HighResSampleStorage): + """HighRes Biosolutions SteriStore plate store.""" + + _model_name = "SteriStore" diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index b49b4ad6d54..8d3395b486c 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -1,10 +1,22 @@ +import asyncio +import inspect import unittest from typing import Dict, List +from unittest.mock import AsyncMock -from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver -from pylabrobot.highres.sample_storage.errors import HighResSampleStorageError +from pylabrobot.events import EventBus, PLREvent, use_event_bus +from pylabrobot.high_res.sample_storage import AmbiStore, SteriStore, TundraStore +from pylabrobot.high_res.sample_storage.driver import HighResSampleStorage +from pylabrobot.high_res.sample_storage.driver.errors import ( + HighResSampleStorageError, + HighResSampleStorageProtocolError, + PlateNotFoundError, +) +from pylabrobot.high_res.sample_storage.driver.models import get_model_info +from pylabrobot.high_res.sample_storage.driver.settings import HighResSampleStorageSettings +from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Well -# Real responses captured from a TundraStore (firmware 3.0.0.119, serial +# Real responses captured from a SteriStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. CAPTURES: Dict[str, List[str]] = { "version": [ @@ -61,15 +73,17 @@ 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.captures = {command: list(lines) for command, lines in captures.items()} self.written: List[str] = [] self._queue: List[str] = [] + self.setup_calls = 0 + self.stop_calls = 0 async def setup(self): - pass + self.setup_calls += 1 async def stop(self): - pass + self.stop_calls += 1 async def write(self, data: bytes, timeout=None): command = data.decode("ascii").rstrip("\r\n") @@ -80,18 +94,163 @@ 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): +class TimeoutAfterAckSocket(FakeSocket): + """Return one acknowledgement and then simulate a stalled device response.""" + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + if len(self._queue) == 1: + raise TimeoutError("simulated response timeout") + return await super().readuntil(separator=separator, timeout=timeout) + + +class HighResSampleStorageTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.driver = HighResSampleStorageDriver(host="10.253.253.253") + self.driver = SteriStore(host="10.253.253.253", name="sample_store", racks=[]) self.socket = FakeSocket(CAPTURES) self.driver.io = self.socket # type: ignore[assignment] - self.retrieval = self.driver.automated_retrieval + self.retrieval = self.driver + + async def test_setup_loads_device_nests_without_locations(self): + await self.driver.setup() + + self.assertEqual(self.socket.written, ["version", "environmentstatus", "neststatus"]) + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) + self.assertTrue(all(nest.parent is self.driver for nest in self.driver.nests)) + self.assertTrue(all(nest.location is None for nest in self.driver.nests)) + self.assertEqual( + set(self.driver.environment.parameters), {"TEMP", "RH", "CO2", "O2", "TANK1", "TANK2"} + ) + + async def test_setup_reuses_nests_when_called_again(self): + await self.driver.setup() + original_nests = list(self.driver.nests) + + await self.driver.setup() + + self.assertEqual(self.driver.nests, original_nests) + self.assertTrue( + all(actual is original for actual, original in zip(self.driver.nests, original_nests)) + ) + + async def test_setup_loads_nests_when_device_nest_is_occupied(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 12", + ] + + await self.driver.setup() + + self.assertEqual(self.driver._nest_numbers, [1, 2]) + self.assertEqual(len(self.driver.nests), 2) + self.assertTrue(all(nest.resource is None for nest in self.driver.nests)) + + async def test_repeated_setup_preserves_nest_bookkeeping(self): + await self.driver.setup() + plate = Plate( + name="plate_on_nest", + size_x=127.76, + size_y=85.48, + size_z=14, + ordered_items={}, + ) + self.driver.nests[0].assign_child_resource(plate, location=Coordinate.zero()) + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "2: CLEAR", + "3: CLEAR", + "OK! neststatus 12", + ] + + await self.driver.setup() + + self.assertEqual(self.driver._nest_numbers, [1, 2]) + self.assertIs(self.driver.nests[0].resource, plate) + + async def test_setup_keeps_user_configured_model(self): + driver = HighResSampleStorage( + host="10.253.253.253", name="generic_store", racks=[], model="TundraStore" + ) + driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] + + await driver.setup() + + self.assertEqual(driver.model, "TundraStore") + self.assertEqual(driver.model_info, get_model_info("TundraStore")) + self.assertEqual(driver.environment.temperature_range, (-20.0, 4.0)) + + async def test_device_report_does_not_add_environment_control(self): + driver = HighResSampleStorage( + host="10.253.253.253", name="generic_store", racks=[], model="AmbiStore" + ) + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + + self.assertEqual(socket.written, ["version", "neststatus"]) + self.assertFalse(hasattr(driver, "environment")) + + async def test_unverified_models_warn_during_setup_without_hiding_signature(self): + for model_class in (AmbiStore, TundraStore): + driver = model_class(host="10.253.253.253", name="unverified", racks=[]) + driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] + with self.assertLogs( + "pylabrobot.high_res.sample_storage.driver.driver", level="WARNING" + ) as logs: + await driver.setup() + self.assertIn("not been verified against hardware", " ".join(logs.output)) + self.assertIn("host: str", str(inspect.signature(model_class))) 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_send_command_requires_acknowledgement_first(self): + self.socket.captures["bad"] = ["OK! bad 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "expected ACK! envelope"): + await self.driver.send_command("bad") + + async def test_send_command_validates_acknowledged_command(self): + self.socket.captures["bad"] = ["ACK! other 1", "OK! other 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "ACK echoed command 'other'"): + await self.driver.send_command("bad") + + async def test_send_command_validates_completion_command(self): + self.socket.captures["bad"] = ["ACK! bad 1", "OK! other 1"] + + with self.assertRaisesRegex( + HighResSampleStorageProtocolError, "completion echoed command 'other'" + ): + await self.driver.send_command("bad") + + async def test_send_command_validates_completion_command_id(self): + self.socket.captures["bad"] = ["ACK! bad 1", "OK! bad 2"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "does not match ACK ID '1'"): + await self.driver.send_command("bad") + + async def test_send_command_rejects_duplicate_acknowledgement(self): + self.socket.captures["bad"] = ["ACK! bad 1", "ACK! bad 1", "OK! bad 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "received a second ACK"): + await self.driver.send_command("bad") + + async def test_send_command_closes_transport_after_response_timeout(self): + socket = TimeoutAfterAckSocket({"slow": ["ACK! slow 1", "OK! slow 1"]}) + self.driver.io = socket # type: ignore[assignment] + + with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): + await self.driver.send_command("slow") + + self.assertEqual(socket.stop_calls, 1) + async def test_version(self): v = await self.driver.request_version() self.assertEqual(v.product_name, "SteriStore") @@ -113,6 +272,18 @@ async def test_nest_status(self): nests = await self.retrieval.request_nest_status() self.assertEqual(nests, {1: "clear", 2: "clear"}) + async def test_plate_available_nest_is_occupied(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 12", + ] + + self.assertEqual(await self.retrieval.request_nest_status(), {1: "occupied", 2: "clear"}) + self.assertTrue(await self.retrieval.request_nest_is_holding(1)) + self.assertFalse(await self.retrieval.request_nest_is_holding(2)) + async def test_plate_on_spatula(self): self.assertFalse(await self.retrieval.request_spatula_is_holding()) @@ -127,13 +298,192 @@ async def test_environment_parsing(self): 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_temperature_reads_temp_channel(self): + self.assertAlmostEqual(await self.driver.environment.request_current_temperature(), 21.9) + self.assertAlmostEqual(await self.driver.environment.request_target_temperature(), 22.0) + self.assertTrue(self.driver.environment.supports_active_cooling) + self.assertTrue(self.driver.environment.supports_heating) + self.assertEqual(self.driver.environment.temperature_range, (4.0, 100.0)) - 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) + def test_model_info(self): + self.assertEqual(get_model_info("TundraStore").temperature_range, (-20.0, 4.0)) + self.assertTrue(get_model_info("TundraStore").supports_active_cooling) + self.assertFalse(get_model_info("TundraStore").supports_heating) + self.assertTrue(get_model_info("TundraStore").supports_humidity_control) + self.assertIsNone(get_model_info("TundraStore").humidity_range) + self.assertEqual(get_model_info("SteriStore").humidity_range, (0.0, 0.98)) + self.assertTrue(get_model_info("SteriStore").supports_co2_control) + self.assertFalse(get_model_info("AmbiStore").has_environment_control) + + def test_partial_settings_preserve_missing_and_extra_firmware_keys(self): + settings = HighResSampleStorageSettings.from_lines( + [ + "MACHINE_TYPE = FutureStore3", + "REST_SERVER_PORT = 1000", + "CAROUSEL_VELOCITY = 12.5", + "FUTURE_OPTION = enabled", + ] + ) + + self.assertEqual(settings.machine_type, "FutureStore3") + self.assertEqual(settings.rest_server_port, 1000) + self.assertEqual(settings.carousel_velocity, 12.5) + self.assertIsNone(settings.serial_number) + self.assertEqual(settings.extra, {"FUTURE_OPTION": "enabled"}) + self.assertEqual(settings.raw["FUTURE_OPTION"], "enabled") + + async def test_temperature_range_is_validated(self): + with self.assertRaises(ValueError): + await self.driver.environment.set_temperature(101) + self.assertEqual(self.socket.written, []) + + async def test_installed_temperature_limit_is_validated(self): + self.socket.captures["environmentstatus"] = [ + "ACK! environmentstatus 32", + "TEMP:21.9/22.0/80.0", + "OK! environmentstatus 32", + ] + + with self.assertRaisesRegex(ValueError, "installed limit of 80 C"): + await self.driver.environment.set_temperature(90) + + self.assertEqual(self.socket.written, ["environmentstatus"]) + + async def test_humidity_reads_rh_as_fraction(self): + self.assertAlmostEqual(await self.driver.environment.request_current_humidity(), 0.547) + self.assertAlmostEqual(await self.driver.environment.request_target_humidity(), 0.0) + self.assertTrue(self.driver.environment.supports_humidity_control) + + async def test_gas_levels_are_fractions(self): + self.assertAlmostEqual(await self.driver.environment.request_current_co2(), 0.0) + self.assertAlmostEqual(await self.driver.environment.request_target_co2(), 0.05) + self.assertAlmostEqual(await self.driver.environment.request_current_o2(), 0.205) + self.assertAlmostEqual(await self.driver.environment.request_target_o2(), 0.05) + self.assertTrue(self.driver.environment.supports_co2_control) + self.assertTrue(self.driver.environment.supports_o2_control) + + async def test_tank_pressures(self): + self.assertEqual( + await self.driver.environment.request_tank_pressures(), {"TANK1": 135.0, "TANK2": 135.0} + ) + + async def test_environment_setters_convert_fractions_to_percent(self): + commands = [ + "environmentset TEMP 37", + "environmentset RH 90", + "environmentset CO2 5", + "environmentset O2 10", + ] + for command_id, command in enumerate(commands, start=50): + self.socket.captures[command] = [ + f"ACK! {command} {command_id}", + f"OK! {command} {command_id}", + ] + + await self.driver.environment.set_temperature(37) + await self.driver.environment.set_humidity(0.90) + await self.driver.environment.set_co2(0.05) + await self.driver.environment.set_o2(0.10) + + self.assertEqual( + self.socket.written, + [ + "environmentstatus", + "environmentset TEMP 37", + "environmentstatus", + "environmentset RH 90", + "environmentstatus", + "environmentset CO2 5", + "environmentstatus", + "environmentset O2 10", + ], + ) + + async def test_environment_control_can_be_enabled_and_disabled(self): + commands = ["environment enable co2", "environment disable co2"] + for command_id, command in enumerate(commands, start=60): + self.socket.captures[command] = [ + f"ACK! {command} {command_id}", + f"OK! {command} {command_id}", + ] + + await self.driver.environment.start_co2_control() + await self.driver.environment.stop_co2_control() + + self.assertEqual( + self.socket.written, + [ + "environmentstatus", + "environment enable co2", + "environmentstatus", + "environment disable co2", + ], + ) + + async def test_environment_control_emits_operation_events(self): + self.socket.captures["environmentset TEMP 37"] = [ + "ACK! environmentset TEMP 37 65", + "OK! environmentset TEMP 37 65", + ] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.environment.set_temperature(37) + + self.assertEqual( + [event.name for event in events], + [ + "temperature_controller.set_temperature.started", + "temperature_controller.set_temperature.completed", + ], + ) + self.assertEqual(events[0].data["device"]["name"], "sample_store") + self.assertEqual(events[0].data["resources"], []) + self.assertEqual(events[0].data["target_temperature"], 37.0) + self.assertEqual(events[0].context["operation_id"], events[1].context["operation_id"]) + + async def test_environment_validation_emits_failed_operation_event(self): + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus), self.assertRaises(ValueError): + await self.driver.environment.set_temperature(101) + + self.assertEqual( + [event.name for event in events], + [ + "temperature_controller.set_temperature.started", + "temperature_controller.set_temperature.failed", + ], + ) + self.assertEqual(events[1].data["error_type"], "ValueError") + self.assertEqual(events[0].context["operation_id"], events[1].context["operation_id"]) + + async def test_environment_fraction_ranges_are_validated(self): + with self.assertRaises(ValueError): + await self.driver.environment.set_humidity(0.99) + with self.assertRaises(ValueError): + await self.driver.environment.set_co2(1.01) + with self.assertRaises(ValueError): + await self.driver.environment.set_o2(-0.01) + self.assertEqual(self.socket.written, []) + + async def test_missing_optional_channel_cannot_be_controlled(self): + self.socket.captures["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", + "OK! environmentstatus 31", + ] + + with self.assertRaisesRegex(NotImplementedError, "does not control O2"): + await self.driver.environment.set_o2(0.10) + + self.assertFalse(self.driver.environment.supports_o2_control) async def test_stacker_dimensions(self): dims = await self.retrieval.request_stacker_dimensions() @@ -143,6 +493,107 @@ async def test_stacker_dimensions(self): self.assertAlmostEqual(dims[1].slot_height, 22.867) self.assertEqual(dims[1].slot_count, 24) + async def test_barcode_scan_requires_clear_nests(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 70", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 70", + ] + + with self.assertRaisesRegex(RuntimeError, "plates are present on nests 1"): + await self.driver.request_stacker_barcodes(2, 1) + + self.assertEqual(self.socket.written, ["neststatus"]) + + async def test_barcode_scan_with_clear_nests(self): + self.socket.captures["barcode 2 1"] = [ + "ACK! barcode 2 1 71", + "2 1: EMPTY", + "OK! barcode 2 1 71", + ] + + self.assertEqual(await self.driver.request_stacker_barcodes(2, 1), ["2 1: EMPTY"]) + self.assertEqual(self.socket.written, ["neststatus", "barcode 2 1"]) + + async def test_barcode_arguments_are_validated_before_querying_device(self): + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes(0) + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes("all", 1) + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes(2, 0) + self.assertEqual(self.socket.written, []) + + async def test_open_all_doors_accepts_confirmed_firmware_error(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Entering calibration mode.", + "ERROR! openalldoors 72", + ] + self.socket.captures["doorstatus"] = [ + "ACK! doorstatus 73", + "User Door: CLOSED", + "RI: OPEN", + "SEAL: OPEN", + "RO1: OPEN", + "RO2: OPEN", + "OK! doorstatus 73", + ] + + await self.driver.open_all_doors() + + self.assertEqual(self.socket.written, ["openalldoors", "doorstatus"]) + + async def test_open_all_doors_waits_until_transitional_doors_reach_open(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Entering calibration mode.", + "ERROR! openalldoors 72", + ] + door_status = AsyncMock( + side_effect=[ + {"RI": "open", "SEAL": "opening"}, + {"RI": "open", "SEAL": "open"}, + ] + ) + self.driver.request_door_status = door_status # type: ignore[method-assign] + + await self.driver.open_all_doors() + + self.assertEqual(door_status.await_count, 2) + + async def test_open_all_doors_does_not_accept_stuck_opening_state(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Error 1: failed to finish opening doors", + "ERROR! openalldoors 72", + ] + self.driver._motion_timeout = 0 + self.driver.request_door_status = AsyncMock( # type: ignore[method-assign] + return_value={"RI": "open", "SEAL": "opening"} + ) + + with self.assertRaises(HighResSampleStorageError): + await self.driver.open_all_doors() + + async def test_open_all_doors_preserves_error_if_a_robot_door_remains_closed(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 74", + "Error 1: failed to open doors", + "ERROR! openalldoors 74", + ] + self.socket.captures["doorstatus"] = [ + "ACK! doorstatus 75", + "User Door: CLOSED", + "RI: OPEN", + "SEAL: CLOSED", + "OK! doorstatus 75", + ] + + with self.assertRaises(HighResSampleStorageError): + await self.driver.open_all_doors() + async def test_home_error_raises_with_stack_detail(self): with self.assertRaises(HighResSampleStorageError) as ctx: await self.retrieval.home() @@ -151,26 +602,302 @@ async def test_home_error_raises_with_stack_detail(self): 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) + 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) + async def test_tray_maps_to_nest(self): + await self.driver.setup() + # 0-based tray -> device-reported nest number. 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_index_selects_nest(self): - # default_tray_index is 0-based; tray 1 -> device nest 2. - driver = HighResSampleStorageDriver(host="10.253.253.253", default_tray_index=1) - self.assertEqual(driver.automated_retrieval.default_tray_index, 1) - self.assertEqual(driver.automated_retrieval._nest_for_tray(None), 2) - - async def test_set_humidity_unsupported(self): + def test_serialize_is_not_implemented(self): with self.assertRaises(NotImplementedError): - await self.driver.humidity.set_humidity(0.5) + self.driver.serialize() + + +class HighResSampleStorageBookkeepingTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.site = PlateHolder( + name="site_1", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + rack = PlateCarrier(name="rack_1", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(self.site, location=Coordinate.zero(), spot=0) + well = Well(name="A1", size_x=8, size_y=8, size_z=10) + well.location = Coordinate(10, 10, 2) + self.plate = Plate( + name="plate", size_x=127.76, size_y=85.48, size_z=14, ordered_items={"A1": well} + ) + self.site.assign_child_resource(self.plate) + + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks=[rack]) + self.socket = FakeSocket(CAPTURES) + self.driver.io = self.socket # type: ignore[assignment] + + async def asyncSetUp(self): + await self.driver.setup() + self.socket.written.clear() + + def _set_nest_status(self, nest_1: str, nest_2: str = "CLEAR") -> None: + """Configure the fake live nest-sensor response.""" + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + f"1: {nest_1}", + f"2: {nest_2}", + "OK! neststatus 12", + ] + + async def test_fetch_moves_plate_resource_to_nest(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + + result = await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.site.resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + + async def test_fetch_refuses_physically_occupied_destination_nest(self): + self._set_nest_status("PLATE_AVAILABLE") + + with self.assertRaisesRegex(RuntimeError, "nest 1 must be clear"): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.site.resource, self.plate) + + async def test_fetch_emits_correlated_operation_and_bookkeeping_events(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertEqual( + [event.name for event in events], + [ + "incubator.fetch_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.fetch_plate.completed", + ], + ) + operation_id = events[0].context["operation_id"] + self.assertTrue(all(event.context["operation_id"] == operation_id for event in events)) + self.assertEqual(events[0].data["source"]["name"], self.site.name) + self.assertEqual(events[0].data["destination"]["name"], self.driver.nests[0].name) + + def test_explicit_carrier_spots_define_physical_slots(self): + first_inserted = PlateHolder( + name="spot_7", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + second_inserted = PlateHolder( + name="spot_2", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + rack = PlateCarrier(name="out_of_order", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(first_inserted, location=Coordinate.zero(), spot=7) + rack.assign_child_resource(second_inserted, location=Coordinate.zero(), spot=2) + + driver = HighResSampleStorage( + host="10.253.253.253", name="explicit_slots", racks=[rack], model="SteriStore" + ) + + self.assertEqual(driver._locate(first_inserted), (1, 8)) + self.assertEqual(driver._locate(second_inserted), (1, 3)) + + def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): + short = PlateHolder(name="short", size_x=127.76, size_y=85.48, size_z=16, pedestal_size_z=0) + tall = PlateHolder(name="tall", size_x=127.76, size_y=85.48, size_z=18, pedestal_size_z=0) + rack = PlateCarrier(name="height_rack", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(short, location=Coordinate.zero(), spot=0) + rack.assign_child_resource(tall, location=Coordinate.zero(), spot=1) + plate = Plate(name="lidded", size_x=127.76, size_y=85.48, size_z=14, ordered_items={}) + plate.assign_child_resource( + Lid(name="lid", size_x=127.76, size_y=85.48, size_z=5, nesting_z_height=2) + ) + driver = HighResSampleStorage( + host="10.253.253.253", name="height_store", racks=[rack], model="SteriStore" + ) + + self.assertIs(driver.find_smallest_site_for_plate(plate), tall) + + async def test_fetch_by_name_moves_plate_resource_to_nest(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + + result = await self.driver.fetch_plate_to_loading_tray("plate", tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.site.resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + + async def test_transfer_lock_covers_validation_motion_and_bookkeeping(self): + transfer_started = asyncio.Event() + release_transfer = asyncio.Event() + pick_calls = [] + + async def blocked_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + pick_calls.append((stacker, slot, nest, close_door)) + transfer_started.set() + await release_transfer.wait() + + self.driver._pick = blocked_pick # type: ignore[method-assign] + first = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0)) + await asyncio.wait_for(transfer_started.wait(), timeout=1) + + second = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=1)) + await asyncio.sleep(0) + + # The second transfer must not even validate against the stale resource + # tree while the first hardware move is in progress. + self.assertEqual(pick_calls, [(1, 1, 1, True)]) + self.assertFalse(second.done()) + + release_transfer.set() + self.assertIs(await first, self.plate) + with self.assertRaisesRegex(ValueError, "not a known stacker slot"): + await second + self.assertEqual(pick_calls, [(1, 1, 1, True)]) + + def test_inventory_queries_use_resource_tree(self): + self.assertEqual(self.driver.get_num_free_sites(), 0) + self.assertIs(self.driver.get_site_by_plate_name("plate"), self.site) + + async def test_take_in_plate_moves_nest_resource_to_selected_site(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + + result = await self.driver.take_in_plate(tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.driver.nests[0].resource) + self.assertIs(self.site.resource, self.plate) + + async def test_take_in_plate_emits_correlated_operation_and_bookkeeping_events(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.take_in_plate(tray_index=0) + + self.assertEqual( + [event.name for event in events], + [ + "incubator.take_in_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.take_in_plate.completed", + ], + ) + operation_id = events[0].context["operation_id"] + self.assertTrue(all(event.context["operation_id"] == operation_id for event in events)) + self.assertEqual(events[0].data["source"]["name"], self.driver.nests[0].name) + self.assertEqual(events[0].data["destination"]["name"], self.site.name) + + async def test_store_moves_plate_resource_to_site(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertIsNone(self.driver.nests[0].resource) + self.assertIs(self.site.resource, self.plate) + self.assertIs(self.plate.parent, self.site) + + async def test_store_refuses_logical_plate_when_physical_nest_is_clear(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + + with self.assertRaisesRegex(RuntimeError, "nest 1 must be occupied"): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.driver.nests[0].resource, self.plate) + + async def test_transfer_plate_between_nests_updates_hardware_and_resource_tree(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + self._set_nest_status("CLEAR", "PLATE_AVAILABLE") + self.socket.captures["nesttransfer 2 1"] = [ + "ACK! nesttransfer 2 1 50", + "OK! nesttransfer 2 1 50", + ] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + result = await self.driver.transfer_plate_between_nests(1, 0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.driver.nests[1].resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertEqual(self.socket.written, ["neststatus", "nesttransfer 2 1"]) + self.assertEqual( + [event.name for event in events], + [ + "incubator.transfer_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.transfer_plate.completed", + ], + ) + + async def test_transfer_plate_between_nests_requires_live_sensor_agreement(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + + with self.assertRaisesRegex(RuntimeError, "nest 2 must be occupied"): + await self.driver.transfer_plate_between_nests(1, 0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.driver.nests[1].resource, self.plate) + + async def test_failed_fetch_leaves_resource_in_site(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "Error 1: 42: No plate detected", + "ERROR! pick 1 1 1 42", + ] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 43", + "homed", + "OK! homedstatus 43", + ] + + with self.assertRaises(PlateNotFoundError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertIs(self.site.resource, self.plate) + self.assertIsNone(self.driver.nests[0].resource) + + async def test_failed_store_leaves_resource_on_nest(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = [ + "ACK! place 1 1 1 44", + "Error 1: 44: Place failed", + "ERROR! place 1 1 1 44", + ] + + with self.assertRaises(HighResSampleStorageError): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertIsNone(self.site.resource) if __name__ == "__main__": diff --git a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py index b1b6698fde8..4c57f16d645 100644 --- a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -1,8 +1,11 @@ import unittest from typing import List -from pylabrobot.highres.sample_storage.driver import HighResSampleStorageDriver -from pylabrobot.highres.sample_storage.errors import HighResSampleStorageFault, PlateNotFoundError +from pylabrobot.high_res.sample_storage.driver import HighResSampleStorage +from pylabrobot.high_res.sample_storage.driver.errors import ( + HighResSampleStorageFault, + PlateNotFoundError, +) def _ok(command: str, cid: int) -> List[str]: @@ -39,8 +42,8 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: class HighResSampleStorageRecoveryTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.driver = HighResSampleStorageDriver(host="10.253.253.253") - self.retrieval = self.driver.automated_retrieval + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks=[]) + self.retrieval = self.driver 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). @@ -57,7 +60,7 @@ async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): ) self.driver.io = sock # type: ignore[assignment] with self.assertRaises(PlateNotFoundError): - await self.retrieval.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"]) @@ -75,7 +78,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(HighResSampleStorageFault): - await self.retrieval.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): @@ -93,66 +96,102 @@ async def test_dehomed_pick_raises_fault(self): ) self.driver.io = sock # type: ignore[assignment] with self.assertRaises(HighResSampleStorageFault): - await self.retrieval.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]: return [f"ACK! homedstatus {cid}", "homed", f"OK! homedstatus {cid}"] - def _status(self, cid: int, y: float) -> List[str]: + def _status(self, cid: int, y: float, z: float = 0.0) -> List[str]: return [ f"ACK! status {cid}", "Carousel: 0.0", f"Y axis: {y}", - "Z axis: 0.0", + f"Z axis: {z}", f"OK! status {cid}", ] + def _plate_status(self, cid: int, holding: bool = False) -> List[str]: + state = "PLATE_AVAILABLE" if holding else "NO_PLATE" + return [f"ACK! platestatus {cid}", state, f"OK! platestatus {cid}"] + 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.request_is_parked()) + async def test_request_is_parked_requires_retracted_z(self): + sock = ScriptedSocket( + [("homedstatus", self._homed(1)), ("status", self._status(2, y=0.0, z=384.0))] + ) + self.driver.io = sock # type: ignore[assignment] + 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 # (the lie), then confirm parked via the slide position. sock = ScriptedSocket( [ - ("enable", _ok("enable", 1)), - ("spatulaout", _ok("spatulaout", 2)), - ("home", _ok("home", 3)), - ("homedstatus", self._homed(4)), - ("status", self._status(5, 0.0)), + ("platestatus", self._plate_status(1)), + ("enable", _ok("enable", 2)), + ("spatulaout", _ok("spatulaout", 3)), + ("home", _ok("home", 4)), + ("homedstatus", self._homed(5)), + ("status", self._status(6, 0.0)), ] ) self.driver.io = sock # type: ignore[assignment] self.assertTrue(await self.retrieval.recover()) - self.assertEqual(sock.commands, ["enable", "spatulaout", "home", "homedstatus", "status"]) + self.assertEqual( + sock.commands, ["platestatus", "enable", "spatulaout", "home", "homedstatus", "status"] + ) 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 + ("platestatus", self._plate_status(1)), + ("enable", _ok("enable", 2)), + ("spatulaout", _ok("spatulaout", 3)), + ("home", _ok("home", 4)), + ("homedstatus", self._homed(5)), + ("status", self._status(6, 255.9999)), # still extended -> retry + ("enable", _ok("enable", 7)), + ("spatulaout", _ok("spatulaout", 8)), + ("home", _ok("home", 9)), + ("homedstatus", self._homed(10)), + ("status", self._status(11, 0.0)), # now retracted ] ) self.driver.io = sock # type: ignore[assignment] self.assertTrue(await self.retrieval.recover()) + async def test_recover_refuses_plate_on_spatula(self): + sock = ScriptedSocket([("platestatus", self._plate_status(1, holding=True))]) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaisesRegex(RuntimeError, "holding a plate"): + await self.retrieval.recover() + + self.assertEqual(sock.commands, ["platestatus"]) + + async def test_unsafe_place_raises_fault(self): + unsafe = [ + "ACK! place 2 5 1 1", + "Error 1: 1: Z height is unsafe for rotation, check machine", + "ERROR! place 2 5 1 1", + ] + sock = ScriptedSocket([("place 2 5 1", unsafe)]) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._place(2, 5, 1) + async def test_place_default_leaves_doors_sealed(self): sock = ScriptedSocket([("place 2 5 1", _ok("place 2 5 1", 1))]) self.driver.io = sock # type: ignore[assignment] - await self.retrieval.place(2, 5, 1) # close_door=True default + 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): @@ -163,7 +202,7 @@ async def test_place_close_door_false_reopens(self): ] ) self.driver.io = sock # type: ignore[assignment] - await self.retrieval.place(2, 5, 1, close_door=False) + 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): @@ -174,7 +213,7 @@ async def test_pick_close_door_false_reopens(self): ] ) self.driver.io = sock # type: ignore[assignment] - await self.retrieval.pick(2, 5, 1, close_door=False) + await self.retrieval._pick(2, 5, 1, close_door=False) self.assertEqual(sock.commands, ["pick 2 5 1", "openalldoors"]) diff --git a/pylabrobot/high_res/sample_storage/tundra_store.py b/pylabrobot/high_res/sample_storage/tundra_store.py new file mode 100644 index 00000000000..42174b5c6bf --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tundra_store.py @@ -0,0 +1,11 @@ +from .driver import HighResSampleStorage + + +class TundraStore(HighResSampleStorage): + """HighRes Biosolutions TundraStore refrigerated plate store.""" + + _model_name = "TundraStore" + _verification_warning = ( + "TundraStore support is a work in progress and has not been verified against hardware. " + "Validate it in a controlled setup and report verified behavior so this warning can be removed." + ) From 0c0f77458cc269fbd92bbb42274ceb20237a9fc4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 24 Aug 2026 21:16:07 -0700 Subject: [PATCH 3/8] feat(high_res): add sample store stacker resources --- docs/api/pylabrobot.high_res.rst | 1 + .../ambistore/hello-world.ipynb | 22 +++---- .../high_res/sample-storage/index.md | 5 ++ .../steristore/hello-world.ipynb | 22 +++---- .../tundrastore/hello-world.ipynb | 22 +++---- .../high_res/sample_storage/__init__.py | 1 + .../high_res/sample_storage/driver/driver.py | 60 ++++++++++++++--- .../high_res/sample_storage/stackers.py | 66 +++++++++++++++++++ .../high_res/sample_storage/stackers_tests.py | 59 +++++++++++++++++ .../sample_storage/tests/driver_tests.py | 34 ++++++++++ 10 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 pylabrobot/high_res/sample_storage/stackers.py create mode 100644 pylabrobot/high_res/sample_storage/stackers_tests.py diff --git a/docs/api/pylabrobot.high_res.rst b/docs/api/pylabrobot.high_res.rst index c851949bc51..d0456e29ff1 100644 --- a/docs/api/pylabrobot.high_res.rst +++ b/docs/api/pylabrobot.high_res.rst @@ -23,3 +23,4 @@ pylabrobot.high_res package EnvironmentControl SteriStore TundraStore + high_res_stacker diff --git a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb index 64fab83643d..90a895a3c39 100644 --- a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb @@ -41,7 +41,7 @@ "\n", "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", "\n", - "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." ] }, { @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -61,18 +61,16 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.high_res.sample_storage import AmbiStore\n", - "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "from pylabrobot.high_res.sample_storage import AmbiStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", "\n", - "site = PlateHolder(\n", - " name=\"stacker_1_slot_1\",\n", - " size_x=127.76,\n", - " size_y=85.48,\n", - " size_z=25.0,\n", - " pedestal_size_z=0,\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", ")\n", - "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", - "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "site = rack.sites[0]\n", "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", "well.location = Coordinate(10, 10, 2)\n", "plate = Plate(\n", diff --git a/docs/user_guide/high_res/sample-storage/index.md b/docs/user_guide/high_res/sample-storage/index.md index 8287c13a3b2..c5d54f680a3 100644 --- a/docs/user_guide/high_res/sample-storage/index.md +++ b/docs/user_guide/high_res/sample-storage/index.md @@ -71,6 +71,11 @@ Each rack maps to its one-based device stacker by list position. Within a rack, carrier `spot` maps to the one-based physical slot: spot 0 is device slot 1, spot 1 is slot 2, and so on. Dictionary insertion order does not affect this mapping. +Alternatively, omit `racks` to select device-discovery mode. During `setup()`, the driver reads the +configured zero offset, slot height, and slot count with the read-only `getstackerdimensions` +command and creates empty stacker resources. Passing `racks=[]` explicitly represents a store with +no configured racks and does not enable discovery. + During setup, the device-reported transfer nests become `store.nests`. Their locations relative to the store are left undefined because they depend on the surrounding robot installation. Setup does not invent plate resources for occupied nests; assign any already-present plates to the matching diff --git a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb index 8fea7b8fb71..73222a1ff23 100644 --- a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb @@ -41,7 +41,7 @@ "\n", "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", "\n", - "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." ] }, { @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -61,18 +61,16 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.high_res.sample_storage import SteriStore\n", - "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "from pylabrobot.high_res.sample_storage import SteriStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", "\n", - "site = PlateHolder(\n", - " name=\"stacker_1_slot_1\",\n", - " size_x=127.76,\n", - " size_y=85.48,\n", - " size_z=25.0,\n", - " pedestal_size_z=0,\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", ")\n", - "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", - "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "site = rack.sites[0]\n", "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", "well.location = Coordinate(10, 10, 2)\n", "plate = Plate(\n", diff --git a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb index 1132bd1438d..a6f4f6ed540 100644 --- a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb @@ -41,7 +41,7 @@ "\n", "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", "\n", - "The example inventory below represents one known plate physically present in stacker 1, slot 1. Change the rack count, spots, slot heights, and assigned plates to match the actual machine before running motion." + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." ] }, { @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "Carrier spots are zero-based in PLR and map to one-based device slots. The site height is a physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -61,18 +61,16 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.high_res.sample_storage import TundraStore\n", - "from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Well\n", + "from pylabrobot.high_res.sample_storage import TundraStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", "\n", - "site = PlateHolder(\n", - " name=\"stacker_1_slot_1\",\n", - " size_x=127.76,\n", - " size_y=85.48,\n", - " size_z=25.0,\n", - " pedestal_size_z=0,\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", ")\n", - "rack = PlateCarrier(name=\"stacker_1\", size_x=130, size_y=90, size_z=600)\n", - "rack.assign_child_resource(site, location=Coordinate.zero(), spot=0)\n", + "site = rack.sites[0]\n", "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", "well.location = Coordinate(10, 10, 2)\n", "plate = Plate(\n", diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py index 3ca082c4395..54c0a3fc97b 100644 --- a/pylabrobot/high_res/sample_storage/__init__.py +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -18,4 +18,5 @@ VersionInfo, ) from .steri_store import SteriStore +from .stackers import high_res_stacker from .tundra_store import TundraStore diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index f06b8ba93de..c8e1d6e4909 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -14,6 +14,7 @@ Rotation, ) +from ..stackers import high_res_stacker from .environment import EnvironmentControl from .errors import ( HighResSampleStorageAbortedError, @@ -75,7 +76,7 @@ def __init__( self, host: str, name: str, - racks: List[PlateCarrier], + racks: Optional[List[PlateCarrier]] = None, size_x: float = 0, size_y: float = 0, size_z: float = 0, @@ -90,6 +91,9 @@ def __init__( 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``. + racks: Stacker resources in one-based device order. When omitted, ``setup()`` + reads the configured dimensions from the device and creates empty stackers. + Passing an empty list explicitly configures no stackers and disables discovery. port: Remote-control server port (always 1000). read_timeout: Timeout (s) for query/status commands. motion_timeout: Timeout (s) for long-running motion commands @@ -116,15 +120,12 @@ def __init__( self.nests: List[PlateHolder] = [] self._nest_numbers: List[int] = [] - self._racks = racks + self._racks: List[PlateCarrier] = [] self._site_locations: Dict[int, Tuple[int, int]] = {} - for rack_index, rack in enumerate(self._racks): - self.assign_child_resource(rack, location=None) - for spot, site in rack.sites.items(): - if spot < 0: - raise ValueError(f"Rack site spot must be non-negative; got {spot} for {site.name!r}.") - # PLR carrier spots are zero-based; HighRes stacker slots are one-based. - self._site_locations[id(site)] = (rack_index + 1, spot + 1) + self._racks_loaded = racks is not None + if racks is not None: + for rack_index, rack in enumerate(racks): + self._add_rack(rack, stacker=rack_index + 1) # Slide (Y) and lift (Z) positions near zero are retracted. Faulted moves # can leave either axis extended even when firmware still reports homed. @@ -186,6 +187,9 @@ async def _setup_connected(self, home: bool) -> None: version.firmware_version, ) + if not self._racks_loaded: + await self._load_racks_from_device() + if self._model_info.has_environment_control: await self.environment.refresh() @@ -207,6 +211,44 @@ async def _setup_connected(self, home: bool) -> None: if home: await self.home() + def _add_rack(self, rack: PlateCarrier, stacker: int) -> None: + """Attach one rack and index its sites by physical stacker and slot.""" + if stacker < 1: + raise ValueError(f"Stacker number must be positive; got {stacker}.") + self.assign_child_resource(rack, location=None) + self._racks.append(rack) + for spot, site in rack.sites.items(): + if spot < 0: + raise ValueError(f"Rack site spot must be non-negative; got {spot} for {site.name!r}.") + # PLR carrier spots are zero-based; HighRes stacker slots are one-based. + self._site_locations[id(site)] = (stacker, spot + 1) + + async def _load_racks_from_device(self) -> None: + """Create empty stacker resources from ``getstackerdimensions``.""" + dimensions = await self.request_stacker_dimensions() + if not dimensions: + raise RuntimeError("The sample store did not report any stacker dimensions.") + + reported_stackers = set() + for stacker_dimensions in dimensions: + stacker = stacker_dimensions.stacker + if stacker in reported_stackers: + raise RuntimeError(f"The sample store reported stacker {stacker} more than once.") + reported_stackers.add(stacker) + if stacker_dimensions.slot_count == 0: + continue + rack = high_res_stacker( + name=f"{self.name}_stacker_{stacker}", + zero_offset=stacker_dimensions.zero_offset, + slot_height=stacker_dimensions.slot_height, + slot_count=stacker_dimensions.slot_count, + ) + rack.metadata["stacker"] = stacker + self._add_rack(rack, stacker=stacker) + + self._racks_loaded = True + logger.info("Loaded %d configured stackers for %s", len(self._racks), self.name) + async def stop(self): logger.info("Stopping %s", self.name) await self.io.stop() diff --git a/pylabrobot/high_res/sample_storage/stackers.py b/pylabrobot/high_res/sample_storage/stackers.py new file mode 100644 index 00000000000..bf18c9b4060 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/stackers.py @@ -0,0 +1,66 @@ +from pylabrobot.resources import Coordinate, PlateCarrier, PlateHolder + + +_STACKER_SIZE_X = 112.3 +_STACKER_SIZE_Y = 146.6 +_SITE_SIZE_X = 85.48 +_SITE_SIZE_Y = 127.76 + + +def high_res_stacker( + name: str, + *, + zero_offset: float, + slot_height: float, + slot_count: int, +) -> PlateCarrier: + """Create a HighRes sample-store stacker from its configured dimensions. + + The values correspond to one line returned by the device's + ``getstackerdimensions`` command. Carrier spots are zero-based, while site + names use the device's one-based slot numbers. + + Args: + name: Resource name for the stacker. + zero_offset: Vertical position of the first slot. + slot_height: Height and vertical pitch of each slot. + slot_count: Number of slots in the stacker. + + Returns: + A plate carrier containing one holder for each stacker slot. + """ + if zero_offset < 0: + raise ValueError(f"zero_offset must be non-negative; got {zero_offset}.") + if slot_height <= 0: + raise ValueError(f"slot_height must be positive; got {slot_height}.") + if slot_count < 0: + raise ValueError(f"slot_count must be non-negative; got {slot_count}.") + + return PlateCarrier( + name=name, + size_x=_STACKER_SIZE_X, + size_y=_STACKER_SIZE_Y, + size_z=zero_offset + slot_height * slot_count, + sites={ + spot: PlateHolder( + name=f"{name}_slot_{spot + 1}", + size_x=_SITE_SIZE_X, + size_y=_SITE_SIZE_Y, + size_z=slot_height, + pedestal_size_z=0, + ).at( + Coordinate( + x=(_STACKER_SIZE_X - _SITE_SIZE_X) / 2, + y=(_STACKER_SIZE_Y - _SITE_SIZE_Y) / 2, + z=zero_offset + slot_height * spot, + ) + ) + for spot in range(slot_count) + }, + model="high_res_stacker", + metadata={ + "zero_offset": zero_offset, + "slot_height": slot_height, + "slot_count": slot_count, + }, + ) diff --git a/pylabrobot/high_res/sample_storage/stackers_tests.py b/pylabrobot/high_res/sample_storage/stackers_tests.py new file mode 100644 index 00000000000..4bca4108903 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/stackers_tests.py @@ -0,0 +1,59 @@ +import unittest + +from pylabrobot.high_res.sample_storage import high_res_stacker + + +class HighResStackerTests(unittest.TestCase): + def test_builds_reported_stacker_geometry(self): + stacker = high_res_stacker( + name="stacker_2", + zero_offset=5, + slot_height=22.867, + slot_count=24, + ) + + self.assertEqual(stacker.capacity, 24) + self.assertEqual(stacker.get_size_x(), 112.3) + self.assertEqual(stacker.get_size_y(), 146.6) + self.assertAlmostEqual(stacker.get_size_z(), 5 + 22.867 * 24) + self.assertEqual(stacker.model, "high_res_stacker") + self.assertEqual( + stacker.metadata, + {"zero_offset": 5, "slot_height": 22.867, "slot_count": 24}, + ) + + first = stacker.sites[0] + last = stacker.sites[23] + self.assertEqual(first.name, "stacker_2_slot_1") + self.assertEqual(last.name, "stacker_2_slot_24") + self.assertEqual(first.get_size_x(), 85.48) + self.assertEqual(first.get_size_y(), 127.76) + self.assertEqual(first.get_size_z(), 22.867) + self.assertEqual(first.pedestal_size_z, 0) + self.assertIsNotNone(first.location) + self.assertIsNotNone(last.location) + assert first.location is not None + assert last.location is not None + self.assertAlmostEqual(first.location.x, (112.3 - 85.48) / 2) + self.assertAlmostEqual(first.location.y, (146.6 - 127.76) / 2) + self.assertEqual(first.location.z, 5) + self.assertAlmostEqual(last.location.z, 5 + 22.867 * 23) + + def test_allows_disabled_stacker(self): + stacker = high_res_stacker( + name="stacker_1", + zero_offset=0, + slot_height=28.94, + slot_count=0, + ) + + self.assertEqual(stacker.capacity, 0) + self.assertEqual(stacker.get_size_z(), 0) + + def test_rejects_invalid_geometry(self): + with self.assertRaisesRegex(ValueError, "zero_offset"): + high_res_stacker("stacker", zero_offset=-1, slot_height=22.867, slot_count=24) + with self.assertRaisesRegex(ValueError, "slot_height"): + high_res_stacker("stacker", zero_offset=0, slot_height=0, slot_count=24) + with self.assertRaisesRegex(ValueError, "slot_count"): + high_res_stacker("stacker", zero_offset=0, slot_height=22.867, slot_count=-1) diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index 8d3395b486c..ca561e4fed9 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -123,6 +123,40 @@ async def test_setup_loads_device_nests_without_locations(self): set(self.driver.environment.parameters), {"TEMP", "RH", "CO2", "O2", "TANK1", "TANK2"} ) + async def test_setup_discovers_stackers_when_racks_are_omitted(self): + driver = SteriStore(host="10.253.253.253", name="discovered_store") + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + + self.assertEqual( + socket.written, + ["version", "getstackerdimensions", "environmentstatus", "neststatus"], + ) + self.assertEqual(len(driver.racks), 1) + rack = driver.racks[0] + self.assertEqual(rack.name, "discovered_store_stacker_2") + self.assertEqual(rack.capacity, 24) + self.assertEqual(rack.metadata["stacker"], 2) + self.assertEqual(driver._locate(rack.sites[0]), (2, 1)) + self.assertEqual(driver._locate(rack.sites[23]), (2, 24)) + + async def test_repeated_setup_reuses_discovered_stackers(self): + driver = SteriStore(host="10.253.253.253", name="discovered_store") + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + original_racks = list(driver.racks) + await driver.setup() + + self.assertEqual(driver.racks, original_racks) + self.assertTrue( + all(actual is original for actual, original in zip(driver.racks, original_racks)) + ) + self.assertEqual(socket.written.count("getstackerdimensions"), 1) + async def test_setup_reuses_nests_when_called_again(self): await self.driver.setup() original_nests = list(self.driver.nests) From dbc7860e5199f0ff4e960b6ac955f28ebb7d2132 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 24 Aug 2026 21:35:26 -0700 Subject: [PATCH 4/8] refactor(high_res): derive sample store locations --- .../high_res/sample_storage/driver/driver.py | 71 ++++++++++++------- .../sample_storage/tests/driver_tests.py | 24 +++++-- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index c8e1d6e4909..3aeefb8b39c 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -118,10 +118,8 @@ def __init__( # robot-facing coordinates are intentionally undefined relative to the # store, so the corresponding resources are attached with location=None. self.nests: List[PlateHolder] = [] - self._nest_numbers: List[int] = [] - self._racks: List[PlateCarrier] = [] - self._site_locations: Dict[int, Tuple[int, int]] = {} + self._racks_by_number: Dict[int, PlateCarrier] = {} self._racks_loaded = racks is not None if racks is not None: for rack_index, rack in enumerate(racks): @@ -197,8 +195,13 @@ async def _setup_connected(self, home: bool) -> None: if not nest_status: raise RuntimeError("The sample store did not report any nests.") if not self.nests: - self._nest_numbers = sorted(nest_status) - for nest_number in self._nest_numbers: + nest_numbers = sorted(nest_status) + expected_nest_numbers = list(range(1, len(nest_numbers) + 1)) + if nest_numbers != expected_nest_numbers: + raise RuntimeError( + f"The sample store must report contiguous nest numbers starting at 1; got {nest_numbers}." + ) + for nest_number in nest_numbers: nest = PlateHolder( name=f"{self.name}_nest_{nest_number}", size_x=127.76, @@ -212,16 +215,16 @@ async def _setup_connected(self, home: bool) -> None: await self.home() def _add_rack(self, rack: PlateCarrier, stacker: int) -> None: - """Attach one rack and index its sites by physical stacker and slot.""" + """Attach one rack under its physical stacker number.""" if stacker < 1: raise ValueError(f"Stacker number must be positive; got {stacker}.") - self.assign_child_resource(rack, location=None) - self._racks.append(rack) + if stacker in self._racks_by_number: + raise ValueError(f"Stacker {stacker} is already configured.") for spot, site in rack.sites.items(): if spot < 0: raise ValueError(f"Rack site spot must be non-negative; got {spot} for {site.name!r}.") - # PLR carrier spots are zero-based; HighRes stacker slots are one-based. - self._site_locations[id(site)] = (stacker, spot + 1) + self.assign_child_resource(rack, location=None) + self._racks_by_number[stacker] = rack async def _load_racks_from_device(self) -> None: """Create empty stacker resources from ``getstackerdimensions``.""" @@ -230,7 +233,7 @@ async def _load_racks_from_device(self) -> None: raise RuntimeError("The sample store did not report any stacker dimensions.") reported_stackers = set() - for stacker_dimensions in dimensions: + for stacker_dimensions in sorted(dimensions, key=lambda value: value.stacker): stacker = stacker_dimensions.stacker if stacker in reported_stackers: raise RuntimeError(f"The sample store reported stacker {stacker} more than once.") @@ -243,11 +246,10 @@ async def _load_racks_from_device(self) -> None: slot_height=stacker_dimensions.slot_height, slot_count=stacker_dimensions.slot_count, ) - rack.metadata["stacker"] = stacker self._add_rack(rack, stacker=stacker) self._racks_loaded = True - logger.info("Loaded %d configured stackers for %s", len(self._racks), self.name) + logger.info("Loaded %d configured stackers for %s", len(self._racks_by_number), self.name) async def stop(self): logger.info("Stopping %s", self.name) @@ -736,13 +738,13 @@ async def clear_abort(self) -> None: @property def racks(self) -> List[PlateCarrier]: - return self._racks + return list(self._racks_by_number.values()) def get_num_free_sites(self) -> int: - return sum(len(rack.get_free_sites()) for rack in self._racks) + return sum(len(rack.get_free_sites()) for rack in self._racks_by_number.values()) def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: - for rack in self._racks: + for rack in self._racks_by_number.values(): for site in rack.sites.values(): if site.resource is not None and site.resource.name == plate_name: return site @@ -755,7 +757,7 @@ def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: plate_height = max(plate_height, lid_location.z + plate.lid.get_size_z()) available = [ site - for rack in self._racks + for rack in self._racks_by_number.values() for site in rack.get_free_sites() if site.get_size_z() >= plate_height ] @@ -783,19 +785,38 @@ def find_random_site(self, plate: Plate) -> PlateHolder: return random.choice(self._find_available_sites_sorted(plate)) def _locate(self, site: PlateHolder) -> Tuple[int, int]: - if id(site) not in self._site_locations: - raise ValueError(f"Site '{site.name}' is not a known stacker slot.") - return self._site_locations[id(site)] + rack = site.parent + if not isinstance(rack, PlateCarrier): + raise ValueError(f"Site '{site.name}' is not attached to a plate carrier.") + + stacker = next( + ( + number + for number, configured_rack in self._racks_by_number.items() + if configured_rack is rack + ), + None, + ) + if stacker is None: + raise ValueError(f"Site '{site.name}' is not in a known stacker.") + + spot = next( + (spot for spot, configured_site in rack.sites.items() if configured_site is site), None + ) + if spot is None: + raise ValueError(f"Site '{site.name}' is not a known slot in stacker {stacker}.") + # PLR carrier spots are zero-based; HighRes stacker slots are one-based. + return stacker, spot + 1 def _nest_for_tray(self, tray_index: int) -> int: - """Map a 0-based tray index to a nest number reported by the device.""" - if not self._nest_numbers: + """Map a 0-based tray index to its one-based device nest number.""" + if not self.nests: raise RuntimeError("Nests have not been loaded; call setup() first.") - if not 0 <= tray_index < len(self._nest_numbers): + if not 0 <= tray_index < len(self.nests): raise ValueError( - f"sample store has trays 0..{len(self._nest_numbers) - 1}; got tray_index={tray_index}." + f"sample store has trays 0..{len(self.nests) - 1}; got tray_index={tray_index}." ) - return self._nest_numbers[tray_index] + return tray_index + 1 async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index: int) -> Plate: async with self._transfer_lock: diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index ca561e4fed9..32fbeb479f1 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -138,7 +138,8 @@ async def test_setup_discovers_stackers_when_racks_are_omitted(self): rack = driver.racks[0] self.assertEqual(rack.name, "discovered_store_stacker_2") self.assertEqual(rack.capacity, 24) - self.assertEqual(rack.metadata["stacker"], 2) + self.assertNotIn("stacker", rack.metadata) + self.assertEqual(driver._racks_by_number, {2: rack}) self.assertEqual(driver._locate(rack.sites[0]), (2, 1)) self.assertEqual(driver._locate(rack.sites[23]), (2, 24)) @@ -178,7 +179,9 @@ async def test_setup_loads_nests_when_device_nest_is_occupied(self): await self.driver.setup() - self.assertEqual(self.driver._nest_numbers, [1, 2]) + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) self.assertEqual(len(self.driver.nests), 2) self.assertTrue(all(nest.resource is None for nest in self.driver.nests)) @@ -201,9 +204,22 @@ async def test_repeated_setup_preserves_nest_bookkeeping(self): await self.driver.setup() - self.assertEqual(self.driver._nest_numbers, [1, 2]) + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) self.assertIs(self.driver.nests[0].resource, plate) + async def test_setup_rejects_noncontiguous_initial_nests(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: CLEAR", + "3: CLEAR", + "OK! neststatus 12", + ] + + with self.assertRaisesRegex(RuntimeError, r"contiguous nest numbers.*\[1, 3\]"): + await self.driver.setup() + async def test_setup_keeps_user_configured_model(self): driver = HighResSampleStorage( host="10.253.253.253", name="generic_store", racks=[], model="TundraStore" @@ -792,7 +808,7 @@ async def blocked_pick(stacker: int, slot: int, nest: int, close_door: bool = Tr release_transfer.set() self.assertIs(await first, self.plate) - with self.assertRaisesRegex(ValueError, "not a known stacker slot"): + with self.assertRaisesRegex(ValueError, "not attached to a plate carrier"): await second self.assertEqual(pick_calls, [(1, 1, 1, True)]) From 789c921f9e87beaf7d1113d21019bedc6863aab2 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 25 Aug 2026 11:33:43 -0700 Subject: [PATCH 5/8] feat(high_res): make sample store inventory state explicit --- docs/api/pylabrobot.high_res.rst | 1 + .../ambistore/hello-world.ipynb | 6 +- .../steristore/hello-world.ipynb | 6 +- .../tundrastore/hello-world.ipynb | 6 +- .../high_res/sample_storage/__init__.py | 1 + .../sample_storage/driver/__init__.py | 2 +- .../high_res/sample_storage/driver/driver.py | 231 +++++++++++++++-- .../sample_storage/tests/driver_tests.py | 233 +++++++++++++++++- .../sample_storage/tests/recovery_tests.py | 2 +- 9 files changed, 441 insertions(+), 47 deletions(-) diff --git a/docs/api/pylabrobot.high_res.rst b/docs/api/pylabrobot.high_res.rst index d0456e29ff1..7971a6c5443 100644 --- a/docs/api/pylabrobot.high_res.rst +++ b/docs/api/pylabrobot.high_res.rst @@ -23,4 +23,5 @@ pylabrobot.high_res package EnvironmentControl SteriStore TundraStore + UnresolvedPlateTransfer high_res_stacker diff --git a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb index 90a895a3c39..a6953c9dbf5 100644 --- a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -81,7 +81,7 @@ " ordered_items={\"A1\": well},\n", ")\n", "site.assign_child_resource(plate)\n", - "racks = [rack]" + "racks = {1: rack}" ] }, { @@ -277,7 +277,7 @@ "source": [ "## Verify or recover the parked state\n", "\n", - "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." ] }, { diff --git a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb index 73222a1ff23..7e1258a7fad 100644 --- a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -81,7 +81,7 @@ " ordered_items={\"A1\": well},\n", ")\n", "site.assign_child_resource(plate)\n", - "racks = [rack]" + "racks = {1: rack}" ] }, { @@ -277,7 +277,7 @@ "source": [ "## Verify or recover the parked state\n", "\n", - "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." ] }, { diff --git a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb index a6f4f6ed540..dc37776c42e 100644 --- a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb +++ b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb @@ -51,7 +51,7 @@ "source": [ "## Describe the physical inventory\n", "\n", - "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." ] }, { @@ -81,7 +81,7 @@ " ordered_items={\"A1\": well},\n", ")\n", "site.assign_child_resource(plate)\n", - "racks = [rack]" + "racks = {1: rack}" ] }, { @@ -277,7 +277,7 @@ "source": [ "## Verify or recover the parked state\n", "\n", - "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked." + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." ] }, { diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py index 54c0a3fc97b..ea6b997d6b9 100644 --- a/pylabrobot/high_res/sample_storage/__init__.py +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -15,6 +15,7 @@ NoFreeSiteError, PlateNotFoundError, StackerDimensions, + UnresolvedPlateTransfer, VersionInfo, ) from .steri_store import SteriStore diff --git a/pylabrobot/high_res/sample_storage/driver/__init__.py b/pylabrobot/high_res/sample_storage/driver/__init__.py index 182f263e7e7..5a90968e7d6 100644 --- a/pylabrobot/high_res/sample_storage/driver/__init__.py +++ b/pylabrobot/high_res/sample_storage/driver/__init__.py @@ -1,4 +1,4 @@ -from .driver import HighResSampleStorage +from .driver import HighResSampleStorage, UnresolvedPlateTransfer from .environment import EnvironmentControl from .errors import ( HighResSampleStorageAbortedError, diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index 3aeefb8b39c..a184fda7b92 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -1,7 +1,8 @@ import asyncio import logging import random -from typing import Dict, List, Literal, Optional, Tuple, Union, cast +from dataclasses import dataclass +from typing import Awaitable, Callable, Dict, List, Literal, Mapping, Optional, Tuple, Union, cast from pylabrobot.events import event_operation, resource_reference from pylabrobot.io.socket import Socket @@ -48,6 +49,22 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class UnresolvedPlateTransfer: + """A plate move whose physical result and PLR state are not reconciled. + + The resource tree remains at its last confirmed state until + :meth:`HighResSampleStorage.resolve_unresolved_transfer` reconciles it. + """ + + plate: Plate + source: PlateHolder + destination: PlateHolder + command: str + error_type: str + error_message: str + + class HighResSampleStorage(Resource): """Base device for HighRes Biosolutions sample stores. @@ -58,6 +75,11 @@ class HighResSampleStorage(Resource): (transfer stations). Fetch and store operations address a particular nest with a 0-based ``tray_index``. + If a plate move is interrupted without a definitive outcome, the driver + exposes it through :attr:`unresolved_transfer` and blocks further plate moves + until :meth:`resolve_unresolved_transfer` reconciles the physical and PLR + locations. + Subclasses set :attr:`_model_name`; callers can override it with ``model``. This configured model selects model-specific behavior. The product name reported by the device is logged during setup but is not used as configuration. @@ -76,7 +98,7 @@ def __init__( self, host: str, name: str, - racks: Optional[List[PlateCarrier]] = None, + racks: Optional[Mapping[int, PlateCarrier]] = None, size_x: float = 0, size_y: float = 0, size_z: float = 0, @@ -91,9 +113,10 @@ def __init__( 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``. - racks: Stacker resources in one-based device order. When omitted, ``setup()`` - reads the configured dimensions from the device and creates empty stackers. - Passing an empty list explicitly configures no stackers and disables discovery. + racks: Mapping of physical stacker numbers to stacker resources. When omitted, + ``setup()`` reads the configured dimensions from the device and creates empty + stackers. Passing an empty mapping explicitly configures no stackers and disables + discovery. port: Remote-control server port (always 1000). read_timeout: Timeout (s) for query/status commands. motion_timeout: Timeout (s) for long-running motion commands @@ -122,8 +145,10 @@ def __init__( self._racks_by_number: Dict[int, PlateCarrier] = {} self._racks_loaded = racks is not None if racks is not None: - for rack_index, rack in enumerate(racks): - self._add_rack(rack, stacker=rack_index + 1) + for stacker in racks: + self._validate_stacker_number(stacker) + for stacker in sorted(racks): + self._add_rack(racks[stacker], stacker=stacker) # Slide (Y) and lift (Z) positions near zero are retracted. Faulted moves # can leave either axis extended even when firmware still reports homed. @@ -148,6 +173,7 @@ def __init__( # transfer also includes resource validation and bookkeeping on either side # of the hardware command. Keep that entire transaction atomic. self._transfer_lock = asyncio.Lock() + self._unresolved_transfer: Optional[UnresolvedPlateTransfer] = None @property def read_timeout(self) -> float: @@ -161,6 +187,11 @@ def motion_timeout(self) -> float: def model_info(self) -> ModelInfo: return self._model_info + @property + def unresolved_transfer(self) -> Optional[UnresolvedPlateTransfer]: + """The plate move whose physical outcome must be reconciled, if any.""" + return self._unresolved_transfer + def serialize(self) -> dict: raise NotImplementedError("HighRes sample store serialization is not implemented yet.") @@ -216,8 +247,7 @@ async def _setup_connected(self, home: bool) -> None: def _add_rack(self, rack: PlateCarrier, stacker: int) -> None: """Attach one rack under its physical stacker number.""" - if stacker < 1: - raise ValueError(f"Stacker number must be positive; got {stacker}.") + self._validate_stacker_number(stacker) if stacker in self._racks_by_number: raise ValueError(f"Stacker {stacker} is already configured.") for spot, site in rack.sites.items(): @@ -226,6 +256,11 @@ def _add_rack(self, rack: PlateCarrier, stacker: int) -> None: self.assign_child_resource(rack, location=None) self._racks_by_number[stacker] = rack + @staticmethod + def _validate_stacker_number(stacker: int) -> None: + if not isinstance(stacker, int) or isinstance(stacker, bool) or stacker < 1: + raise ValueError(f"Stacker number must be a positive integer; got {stacker!r}.") + async def _load_racks_from_device(self) -> None: """Create empty stacker resources from ``getstackerdimensions``.""" dimensions = await self.request_stacker_dimensions() @@ -273,6 +308,8 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L """ if not command or command != command.strip() or "\r" in command or "\n" in command: raise ValueError("command must be a non-empty single line without surrounding whitespace") + if command.partition(" ")[0] in ("pick", "place", "nesttransfer"): + self._require_no_unresolved_transfer() if timeout is None: timeout = self._read_timeout encoded_command = command.encode("ascii") + b"\r\n" @@ -778,6 +815,138 @@ async def _require_nest_states(self, expected: Dict[int, NestState]) -> None: f"but its sensor reports {actual_state or 'missing'}." ) + def _require_no_unresolved_transfer(self) -> None: + transfer = self._unresolved_transfer + if transfer is not None: + raise RuntimeError( + f"Plate location is unresolved after transfer command {transfer.command!r}; " + "call resolve_unresolved_transfer() before another plate move." + ) + + def _record_unresolved_transfer( + self, + *, + plate: Plate, + source: PlateHolder, + destination: PlateHolder, + command: str, + error: BaseException, + ) -> None: + self._unresolved_transfer = UnresolvedPlateTransfer( + plate=plate, + source=source, + destination=destination, + command=command, + error_type=type(error).__name__, + error_message=str(error), + ) + logger.error( + "Plate %s transfer via %r is unresolved between %s and %s: %s", + plate.name, + command, + source.name, + destination.name, + error, + ) + + async def _execute_plate_transfer( + self, + *, + plate: Plate, + source: PlateHolder, + destination: PlateHolder, + command: str, + move: Callable[[], Awaitable[object]], + plate_not_found_is_definitive: bool = False, + ) -> Plate: + """Execute one physical move and commit its resource-tree transition.""" + self._require_no_unresolved_transfer() + try: + await move() + except BaseException as error: + if not (plate_not_found_is_definitive and isinstance(error, PlateNotFoundError)): + self._record_unresolved_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + error=error, + ) + raise + + try: + plate.unassign() + destination.assign_child_resource(plate) + except BaseException as error: + # The device completed the move, but PLR could not commit the same state. + # Keep the transfer unresolved so a caller must reconcile both views. + self._record_unresolved_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + error=error, + ) + raise + return plate + + async def resolve_unresolved_transfer( + self, location: Literal["source", "destination", "unassigned"] + ) -> Plate: + """Reconcile an ambiguous plate move without issuing further motion. + + The spatula must be empty. Any source or destination nest is checked against + its live plate sensor. Stacker slots have no non-destructive presence sensor, + so selecting a stacker endpoint is explicit operator confirmation that the + plate was found there. Use ``"unassigned"`` when inspection establishes that + the plate is outside the modeled source and destination. + """ + async with self._transfer_lock: + transfer = self._unresolved_transfer + if transfer is None: + raise RuntimeError("There is no unresolved plate transfer.") + if location not in ("source", "destination", "unassigned"): + raise ValueError( + f"location must be 'source', 'destination', or 'unassigned'; got {location!r}." + ) + + target = ( + transfer.source + if location == "source" + else transfer.destination + if location == "destination" + else None + ) + if target is not None and transfer.plate.parent is not target: + target.check_can_drop_resource_here(transfer.plate) + + if await self.request_spatula_is_holding(): + raise RuntimeError( + "Cannot resolve the plate location while the spatula reports that it is holding a plate." + ) + + expected_nests: Dict[int, NestState] = {} + for tray_index, nest in enumerate(self.nests): + if nest is transfer.source or nest is transfer.destination: + expected_nests[tray_index + 1] = "occupied" if nest is target else "clear" + if expected_nests: + await self._require_nest_states(expected_nests) + + if target is None: + transfer.plate.unassign() + elif transfer.plate.parent is not target: + transfer.plate.unassign() + target.assign_child_resource(transfer.plate) + + self._unresolved_transfer = None + logger.info( + "Resolved plate %s after %r as %s", + transfer.plate.name, + transfer.command, + target.name if target is not None else "unassigned", + ) + return transfer.plate + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: return self._find_available_sites_sorted(plate)[0] @@ -820,6 +989,7 @@ def _nest_for_tray(self, tray_index: int) -> int: async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index: int) -> Plate: async with self._transfer_lock: + self._require_no_unresolved_transfer() if isinstance(plate, str): stored_site = self.get_site_by_plate_name(plate) stored_resource = stored_site.resource @@ -834,6 +1004,7 @@ async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index nest = self.nests[tray_index] nest.check_can_drop_resource_here(plate) await self._require_nest_states({nest_number: "clear"}) + command = f"pick {stacker} {slot} {nest_number}" with event_operation( "incubator.fetch_plate", @@ -842,11 +1013,14 @@ async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index source=resource_reference(parent), destination=resource_reference(nest), ): - await self._pick(stacker, slot, nest_number) - - plate.unassign() - nest.assign_child_resource(plate) - return plate + return await self._execute_plate_transfer( + plate=plate, + source=parent, + destination=nest, + command=command, + move=lambda: self._pick(stacker, slot, nest_number), + plate_not_found_is_definitive=True, + ) async def take_in_plate( self, @@ -854,6 +1028,7 @@ async def take_in_plate( site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest", ) -> Plate: async with self._transfer_lock: + self._require_no_unresolved_transfer() self._nest_for_tray(tray_index) plate = self.nests[tray_index].resource if not isinstance(plate, Plate): @@ -883,6 +1058,7 @@ async def take_in_plate( async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: async with self._transfer_lock: + self._require_no_unresolved_transfer() self._nest_for_tray(tray_index) nest = self.nests[tray_index] with event_operation( @@ -903,6 +1079,7 @@ async def transfer_plate_between_nests( the device during :meth:`setup`. """ async with self._transfer_lock: + self._require_no_unresolved_transfer() if source_tray_index == destination_tray_index: raise ValueError("Source and destination tray indices must be different.") source_number = self._nest_for_tray(source_tray_index) @@ -914,6 +1091,7 @@ async def transfer_plate_between_nests( raise ResourceNotFoundError(f"No plate on tray {source_tray_index}.") destination.check_can_drop_resource_here(plate) await self._require_nest_states({source_number: "occupied", destination_number: "clear"}) + command = f"nesttransfer {source_number} {destination_number}" with event_operation( "incubator.transfer_plate", @@ -929,12 +1107,13 @@ async def transfer_plate_between_nests( source_number, destination_number, ) - await self.send_command( - f"nesttransfer {source_number} {destination_number}", timeout=self.motion_timeout + return await self._execute_plate_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + move=lambda: self.send_command(command, timeout=self.motion_timeout), ) - plate.unassign() - destination.assign_child_resource(plate) - return plate async def _store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: stacker, slot = self._locate(site) @@ -944,8 +1123,12 @@ async def _store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) - raise ValueError(f"Plate '{plate.name}' is not on tray {tray_index}.") site.check_can_drop_resource_here(plate) await self._require_nest_states({nest_number: "occupied"}) - - await self._place(stacker, slot, nest_number) - - plate.unassign() - site.assign_child_resource(plate) + command = f"place {stacker} {slot} {nest_number}" + + await self._execute_plate_transfer( + plate=plate, + source=nest, + destination=site, + command=command, + move=lambda: self._place(stacker, slot, nest_number), + ) diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index 32fbeb479f1..6c49d682d3d 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -8,7 +8,9 @@ from pylabrobot.high_res.sample_storage import AmbiStore, SteriStore, TundraStore from pylabrobot.high_res.sample_storage.driver import HighResSampleStorage from pylabrobot.high_res.sample_storage.driver.errors import ( + HighResSampleStorageAbortedError, HighResSampleStorageError, + HighResSampleStorageFault, HighResSampleStorageProtocolError, PlateNotFoundError, ) @@ -97,15 +99,19 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: class TimeoutAfterAckSocket(FakeSocket): """Return one acknowledgement and then simulate a stalled device response.""" + def __init__(self, captures: Dict[str, List[str]], timeout_command: str): + super().__init__(captures) + self.timeout_command = timeout_command + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: - if len(self._queue) == 1: + if self.written[-1] == self.timeout_command and len(self._queue) == 1: raise TimeoutError("simulated response timeout") return await super().readuntil(separator=separator, timeout=timeout) class HighResSampleStorageTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.driver = SteriStore(host="10.253.253.253", name="sample_store", racks=[]) + self.driver = SteriStore(host="10.253.253.253", name="sample_store", racks={}) self.socket = FakeSocket(CAPTURES) self.driver.io = self.socket # type: ignore[assignment] self.retrieval = self.driver @@ -143,6 +149,12 @@ async def test_setup_discovers_stackers_when_racks_are_omitted(self): self.assertEqual(driver._locate(rack.sites[0]), (2, 1)) self.assertEqual(driver._locate(rack.sites[23]), (2, 24)) + def test_init_rejects_invalid_physical_stacker_number(self): + rack = PlateCarrier(name="rack", size_x=130, size_y=90, size_z=100) + + with self.assertRaisesRegex(ValueError, "positive integer"): + HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={0: rack}) + async def test_repeated_setup_reuses_discovered_stackers(self): driver = SteriStore(host="10.253.253.253", name="discovered_store") socket = FakeSocket(CAPTURES) @@ -222,7 +234,7 @@ async def test_setup_rejects_noncontiguous_initial_nests(self): async def test_setup_keeps_user_configured_model(self): driver = HighResSampleStorage( - host="10.253.253.253", name="generic_store", racks=[], model="TundraStore" + host="10.253.253.253", name="generic_store", racks={}, model="TundraStore" ) driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] @@ -234,7 +246,7 @@ async def test_setup_keeps_user_configured_model(self): async def test_device_report_does_not_add_environment_control(self): driver = HighResSampleStorage( - host="10.253.253.253", name="generic_store", racks=[], model="AmbiStore" + host="10.253.253.253", name="generic_store", racks={}, model="AmbiStore" ) socket = FakeSocket(CAPTURES) driver.io = socket # type: ignore[assignment] @@ -246,7 +258,7 @@ async def test_device_report_does_not_add_environment_control(self): async def test_unverified_models_warn_during_setup_without_hiding_signature(self): for model_class in (AmbiStore, TundraStore): - driver = model_class(host="10.253.253.253", name="unverified", racks=[]) + driver = model_class(host="10.253.253.253", name="unverified", racks={}) driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] with self.assertLogs( "pylabrobot.high_res.sample_storage.driver.driver", level="WARNING" @@ -293,7 +305,7 @@ async def test_send_command_rejects_duplicate_acknowledgement(self): await self.driver.send_command("bad") async def test_send_command_closes_transport_after_response_timeout(self): - socket = TimeoutAfterAckSocket({"slow": ["ACK! slow 1", "OK! slow 1"]}) + socket = TimeoutAfterAckSocket({"slow": ["ACK! slow 1", "OK! slow 1"]}, timeout_command="slow") self.driver.io = socket # type: ignore[assignment] with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): @@ -682,7 +694,7 @@ def setUp(self): ) self.site.assign_child_resource(self.plate) - self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks=[rack]) + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={1: rack}) self.socket = FakeSocket(CAPTURES) self.driver.io = self.socket # type: ignore[assignment] @@ -708,6 +720,7 @@ async def test_fetch_moves_plate_resource_to_nest(self): self.assertIsNone(self.site.resource) self.assertIs(self.driver.nests[0].resource, self.plate) self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.driver.unresolved_transfer) async def test_fetch_refuses_physically_occupied_destination_nest(self): self._set_nest_status("PLATE_AVAILABLE") @@ -753,11 +766,11 @@ def test_explicit_carrier_spots_define_physical_slots(self): rack.assign_child_resource(second_inserted, location=Coordinate.zero(), spot=2) driver = HighResSampleStorage( - host="10.253.253.253", name="explicit_slots", racks=[rack], model="SteriStore" + host="10.253.253.253", name="explicit_slots", racks={7: rack}, model="SteriStore" ) - self.assertEqual(driver._locate(first_inserted), (1, 8)) - self.assertEqual(driver._locate(second_inserted), (1, 3)) + self.assertEqual(driver._locate(first_inserted), (7, 8)) + self.assertEqual(driver._locate(second_inserted), (7, 3)) def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): short = PlateHolder(name="short", size_x=127.76, size_y=85.48, size_z=16, pedestal_size_z=0) @@ -770,7 +783,7 @@ def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): Lid(name="lid", size_x=127.76, size_y=85.48, size_z=5, nesting_z_height=2) ) driver = HighResSampleStorage( - host="10.253.253.253", name="height_store", racks=[rack], model="SteriStore" + host="10.253.253.253", name="height_store", racks={1: rack}, model="SteriStore" ) self.assertIs(driver.find_smallest_site_for_plate(plate), tall) @@ -865,6 +878,7 @@ async def test_store_moves_plate_resource_to_site(self): self.assertIsNone(self.driver.nests[0].resource) self.assertIs(self.site.resource, self.plate) self.assertIs(self.plate.parent, self.site) + self.assertIsNone(self.driver.unresolved_transfer) async def test_store_refuses_logical_plate_when_physical_nest_is_clear(self): self.plate.unassign() @@ -904,6 +918,7 @@ async def test_transfer_plate_between_nests_updates_hardware_and_resource_tree(s "incubator.transfer_plate.completed", ], ) + self.assertIsNone(self.driver.unresolved_transfer) async def test_transfer_plate_between_nests_requires_live_sensor_agreement(self): self.plate.unassign() @@ -915,6 +930,160 @@ async def test_transfer_plate_between_nests_requires_live_sensor_agreement(self) self.assertEqual(self.socket.written, ["neststatus"]) self.assertIs(self.driver.nests[1].resource, self.plate) + async def test_aborted_nest_transfer_records_both_nest_endpoints(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + self._set_nest_status("CLEAR", "PLATE_AVAILABLE") + self.socket.captures["nesttransfer 2 1"] = [ + "ACK! nesttransfer 2 1 50", + "ABORTED! nesttransfer 2 1 50", + ] + + with self.assertRaises(HighResSampleStorageAbortedError): + await self.driver.transfer_plate_between_nests(1, 0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertIs(transfer.source, self.driver.nests[1]) + self.assertIs(transfer.destination, self.driver.nests[0]) + self.assertIs(self.plate.parent, self.driver.nests[1]) + + async def test_timeout_after_pick_ack_records_unresolved_transfer(self): + command = "pick 1 1 1" + captures = dict(CAPTURES) + captures[command] = [f"ACK! {command} 42", f"OK! {command} 42"] + socket = TimeoutAfterAckSocket(captures, timeout_command=command) + self.driver.io = socket # type: ignore[assignment] + + with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.command, command) + self.assertEqual(transfer.error_type, "TimeoutError") + self.assertIs(self.plate.parent, self.site) + self.assertEqual(socket.stop_calls, 1) + + async def test_cancelled_pick_records_unresolved_transfer(self): + started = asyncio.Event() + + async def blocked_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + started.set() + await asyncio.Event().wait() + + self.driver._pick = blocked_pick # type: ignore[method-assign] + task = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0)) + await asyncio.wait_for(started.wait(), timeout=1) + task.cancel() + + with self.assertRaises(asyncio.CancelledError): + await task + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.command, "pick 1 1 1") + self.assertEqual(transfer.error_type, "CancelledError") + self.assertIs(self.plate.parent, self.site) + + async def test_aborted_pick_records_unresolved_transfer(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "ABORTED! pick 1 1 1 42", + ] + + with self.assertRaises(HighResSampleStorageAbortedError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.error_type, "HighResSampleStorageAbortedError") + self.assertIs(self.plate.parent, self.site) + + async def test_unsafe_pick_records_unresolved_transfer_and_recovery_does_not_clear_it(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "Error 1: 42: Z height is unsafe for rotation, check machine", + "ERROR! pick 1 1 1 42", + ] + + with self.assertRaises(HighResSampleStorageFault): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.error_type, "HighResSampleStorageFault") + + self.socket.captures["enable"] = ["ACK! enable 43", "OK! enable 43"] + self.socket.captures["spatulaout"] = ["ACK! spatulaout 44", "OK! spatulaout 44"] + self.socket.captures["home"] = ["ACK! home 45", "OK! home 45"] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 46", + "homed", + "OK! homedstatus 46", + ] + self.socket.captures["status"] = [ + "ACK! status 47", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 47", + ] + + self.assertTrue(await self.driver.recover()) + self.assertIs(self.driver.unresolved_transfer, transfer) + + async def test_resolve_unresolved_fetch_to_destination_uses_live_sensors(self): + async def ambiguous_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._pick = ambiguous_pick # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self._set_nest_status("PLATE_AVAILABLE") + result = await self.driver.resolve_unresolved_transfer("destination") + + self.assertIs(result, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.site.resource) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(self.socket.written[-2:], ["platestatus", "neststatus"]) + + async def test_reconcile_rejects_spatula_plate_and_can_mark_plate_unassigned(self): + async def ambiguous_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._pick = ambiguous_pick # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.socket.captures["platestatus"] = [ + "ACK! platestatus 9", + "PLATE_AVAILABLE", + "OK! platestatus 9", + ] + with self.assertRaisesRegex(RuntimeError, "spatula reports that it is holding a plate"): + await self.driver.resolve_unresolved_transfer("unassigned") + self.assertIs(self.driver.unresolved_transfer, transfer) + + self.socket.captures["platestatus"] = [ + "ACK! platestatus 10", + "NO_PLATE", + "OK! platestatus 10", + ] + result = await self.driver.resolve_unresolved_transfer("unassigned") + + self.assertIs(result, self.plate) + self.assertIsNone(self.plate.parent) + self.assertIsNone(self.driver.unresolved_transfer) + async def test_failed_fetch_leaves_resource_in_site(self): self.socket.captures["pick 1 1 1"] = [ "ACK! pick 1 1 1 42", @@ -932,8 +1101,9 @@ async def test_failed_fetch_leaves_resource_in_site(self): self.assertIs(self.site.resource, self.plate) self.assertIsNone(self.driver.nests[0].resource) + self.assertIsNone(self.driver.unresolved_transfer) - async def test_failed_store_leaves_resource_on_nest(self): + async def test_failed_store_records_unresolved_transfer_and_blocks_another_move(self): self.plate.unassign() self.driver.nests[0].assign_child_resource(self.plate) self._set_nest_status("PLATE_AVAILABLE") @@ -942,12 +1112,51 @@ async def test_failed_store_leaves_resource_on_nest(self): "Error 1: 44: Place failed", "ERROR! place 1 1 1 44", ] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 45", + "homed", + "OK! homedstatus 45", + ] with self.assertRaises(HighResSampleStorageError): await self.driver.store_plate(self.plate, self.site, tray_index=0) self.assertIs(self.driver.nests[0].resource, self.plate) self.assertIsNone(self.site.resource) + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertIs(transfer.plate, self.plate) + self.assertIs(transfer.source, self.driver.nests[0]) + self.assertIs(transfer.destination, self.site) + self.assertEqual(transfer.command, "place 1 1 1") + self.assertEqual(transfer.error_type, "HighResSampleStorageError") + + written = list(self.socket.written) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.send_command("nesttransfer 1 2") + self.assertEqual(self.socket.written, written) + + async def test_resolve_unresolved_store_to_source_uses_live_nest_sensor(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + + async def ambiguous_place(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._place = ambiguous_place # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + result = await self.driver.resolve_unresolved_transfer("source") + + self.assertIs(result, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(self.socket.written[-2:], ["platestatus", "neststatus"]) if __name__ == "__main__": diff --git a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py index 4c57f16d645..2118d87cc63 100644 --- a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -42,7 +42,7 @@ async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: class HighResSampleStorageRecoveryTests(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks=[]) + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={}) self.retrieval = self.driver async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): From 498d53190c424dd82eb9c953db4d9889c3f8187f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 25 Aug 2026 14:55:59 -0700 Subject: [PATCH 6/8] fix(high_res): address sample store review feedback --- docs/contributor_guide/event-schemas.md | 19 +- .../high_res/sample-storage/index.md | 30 +- .../machine-agnostic-features/event-bus.md | 16 +- .../event-bus/incubator.md | 8 +- .../event-bus/thermal-and-shaking.md | 30 +- .../high_res/sample_storage/driver/driver.py | 264 ++++++++++++++++-- .../sample_storage/driver/environment.py | 6 +- .../sample_storage/tests/driver_tests.py | 84 +++++- pylabrobot/resources/resource.py | 2 + 9 files changed, 403 insertions(+), 56 deletions(-) diff --git a/docs/contributor_guide/event-schemas.md b/docs/contributor_guide/event-schemas.md index 337d48d9d52..ebb268644ae 100644 --- a/docs/contributor_guide/event-schemas.md +++ b/docs/contributor_guide/event-schemas.md @@ -82,6 +82,9 @@ operations. When emitted inside a semantic operation, they inherit its event con | `timeout` | `float` | Requested timeout in PLR's default time unit. | | `target_temperature` | `float` | Configured or requested controller target temperature. | | `current_temperature` | `float` | Controller sensor reading observed by the operation. It is not a resource-temperature measurement unless explicitly documented otherwise. | +| `target_humidity` | `float` | Requested relative-humidity setpoint as a fraction from 0 to 1. | +| `target_co2` | `float` | Requested CO2 concentration as a fraction from 0 to 1. | +| `target_o2` | `float` | Requested O2 concentration as a fraction from 0 to 1. | | `tolerance` | `float` | Allowed temperature difference in PLR's default temperature unit. | | `volume` | `float` | Requested liquid volume in PLR's default volume unit. | @@ -102,6 +105,9 @@ Use these names consistently across operation families: | Requested elapsed time | `duration` | `time`, `duration_s`, `duration_sec`, `seconds` | | Maximum wait | `timeout` | `timeout_s`, `wait_time` | | Requested thermal setpoint | `target_temperature` | `temperature_target`, `set_temperature`, `target_temperature_c` | +| Requested relative humidity | `target_humidity` | `humidity`, `humidity_pct`, `relative_humidity` | +| Requested CO2 concentration | `target_co2` | `co2`, `co2_pct`, `co2_fraction` | +| Requested O2 concentration | `target_o2` | `o2`, `o2_pct`, `o2_fraction` | | Observed controller temperature | `current_temperature` | `actual_temperature`, `measured_temperature`, `current_temperature_c` | | Temperature acceptance range | `tolerance` | `temperature_tolerance`, `tolerance_c` | | Relative centrifugal force | `relative_centrifugal_force` | `g`, `g_force`, `rcf` | @@ -141,6 +147,7 @@ These are state-transition records rather than semantic operation lifecycles. | --- | --- | --- | | `incubator.fetch_plate` | `device`, `resources`, `source`, `destination` | `resources` contains the directly moved plate; endpoints describe the storage site and loading tray when known. | | `incubator.take_in_plate` | `device`, `resources`, `source`, `destination` | Moves the loading-tray plate into storage. A requested selector such as `"random"` or `"smallest"` may identify an unresolved destination at invocation. | +| `incubator.transfer_plate` | `device`, `resources`, `source`, `destination` | Moves a plate directly between two transfer nests or other incubator endpoints. | ### Stackers @@ -201,7 +208,7 @@ record per channel with `channel` and direct `resource` fields. | `liquid_handler.tip_pickup_96` | `device`, `resources` | Direct resource is the operated `TipRack`. | | `liquid_handler.tip_drop_96` | `device`, `resources` | Direct resource is the destination `TipRack` or `Trash`. | -## Shaking and temperature control +## Shaking and environmental control Controllers that are `ResourceHolder`s include their directly loaded resource in `resources` when one is assigned at operation start. @@ -211,9 +218,19 @@ one is assigned at operation start. | `shaker.shake` | `device`, optional `resources`, `speed_rpm`, optional `duration` | Omitted `duration` means shaking continues after the call returns. | | `shaker.stop_shaking` | `device`, optional `resources` | Explicitly stops an indefinite shake. | | `temperature_controller.set_temperature` | `device`, optional `resources`, `target_temperature`, `passive` | Records the requested target and cooling policy. | +| `temperature_controller.activate` | `device`, optional `resources` | Starts active temperature control at the configured setpoint. | | `temperature_controller.wait_for_temperature` | `device`, optional `resources`, `target_temperature`, `timeout`, `tolerance`; **completed only:** `current_temperature` | `current_temperature` is the final controller reading that satisfied tolerance. | | `temperature_controller.hold_temperature` | `device`, optional `resources`, `duration`, optional `target_temperature` | Records a requested dwell without reissuing a setpoint or asserting that a resource reached temperature. | | `temperature_controller.deactivate` | `device`, optional `resources`, optional `target_temperature` | Stops active temperature control. | +| `humidity_controller.set_humidity` | `device`, optional `resources`, `target_humidity` | Records the requested relative-humidity fraction. | +| `humidity_controller.activate` | `device`, optional `resources` | Starts active humidity control at the configured setpoint. | +| `humidity_controller.deactivate` | `device`, optional `resources` | Stops active humidity control. | +| `co2_controller.set_co2` | `device`, optional `resources`, `target_co2` | Records the requested CO2 fraction. | +| `co2_controller.activate` | `device`, optional `resources` | Starts active CO2 control at the configured setpoint. | +| `co2_controller.deactivate` | `device`, optional `resources` | Stops active CO2 control. | +| `o2_controller.set_o2` | `device`, optional `resources`, `target_o2` | Records the requested O2 fraction. | +| `o2_controller.activate` | `device`, optional `resources` | Starts active O2 control at the configured setpoint. | +| `o2_controller.deactivate` | `device`, optional `resources` | Stops active O2 control. | ## Centrifugation diff --git a/docs/user_guide/high_res/sample-storage/index.md b/docs/user_guide/high_res/sample-storage/index.md index c5d54f680a3..e128dd6b024 100644 --- a/docs/user_guide/high_res/sample-storage/index.md +++ b/docs/user_guide/high_res/sample-storage/index.md @@ -58,22 +58,24 @@ Use `ip -brief link` to find ``. Verify the isolated link with ## Setup -Pass the storage racks in stacker order and instantiate the appropriate model. For example: +Pass the storage racks as a mapping from one-based physical stacker numbers to their PLR resources, +then instantiate the appropriate model. Stacker numbers may be sparse. For example: ```python from pylabrobot.high_res.sample_storage import SteriStore +racks = {1: rack_1, 3: rack_3} store = SteriStore(host="192.168.127.60", name="steristore", racks=racks) await store.setup() ``` -Each rack maps to its one-based device stacker by list position. Within a rack, the zero-based +Each mapping key is the rack's one-based device stacker number. Within a rack, the zero-based carrier `spot` maps to the one-based physical slot: spot 0 is device slot 1, spot 1 is slot 2, and -so on. Dictionary insertion order does not affect this mapping. +so on. Mapping insertion order does not affect this relationship. Alternatively, omit `racks` to select device-discovery mode. During `setup()`, the driver reads the configured zero offset, slot height, and slot count with the read-only `getstackerdimensions` -command and creates empty stacker resources. Passing `racks=[]` explicitly represents a store with +command and creates empty stacker resources. Passing `racks={}` explicitly represents a store with no configured racks and does not enable discovery. During setup, the device-reported transfer nests become `store.nests`. Their locations relative to @@ -108,9 +110,11 @@ plate = await store.transfer_plate_between_nests( ) ``` -The driver verifies the live source and destination sensors before every fetch, store, or -nest-to-nest transfer. A mismatch between the physical device and the PLR resource tree stops the -operation before motion begins. +The driver verifies every transfer-nest endpoint with its live presence sensor before motion. The +stacker itself has no non-destructive per-slot presence query: fetching detects an empty source only +during the pick, while storing relies on the PLR resource tree to determine that the destination +slot is free. Keep the modeled stacker inventory synchronized with the physical store; barcode +`EMPTY` is not a plate-presence result. ## Barcode scans @@ -130,3 +134,15 @@ physical slot is empty; use resource bookkeeping or a physical plate-presence wo `request_is_parked()` verifies that the device is homed and that both the spatula slide and lift axes are retracted. `recover()` refuses to move when the spatula sensor reports a plate, because the plate's physical support must be inspected before a safe recovery path can be chosen. + +If a transfer response is interrupted or otherwise ambiguous, the driver keeps the last confirmed +resource assignment in place, records `store.unresolved_transfer`, and blocks additional plate +motion. Inspect the machine, then reconcile the observed result without further motion: + +```python +await store.resolve_unresolved_transfer("source") +``` + +Use `"destination"` when the plate completed the move or `"unassigned"` when it is at neither +modeled endpoint. If the incomplete response invalidated the TCP stream, reconciliation reconnects +automatically before reading the spatula and nest sensors. diff --git a/docs/user_guide/machine-agnostic-features/event-bus.md b/docs/user_guide/machine-agnostic-features/event-bus.md index 59d484df5f1..c87b1c91af0 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus.md +++ b/docs/user_guide/machine-agnostic-features/event-bus.md @@ -118,6 +118,7 @@ events. | --- | --- | | `legacy.machines.Machine` | `machine.setup`, `machine.stop` | | `legacy.storage.Incubator` | `incubator.fetch_plate`, `incubator.take_in_plate` | +| `high_res.sample_storage.HighResSampleStorage` | incubator fetch/take-in/nest transfer; temperature, humidity, CO2, and O2 control when supported | | `legacy.liquid_handling.LiquidHandler` | resource pickup/move/drop; tip pickup/drop; 96-head tip pickup/drop; aspirate; dispense | | `legacy.shaking.Shaker` | `shaker.shake`, `shaker.stop_shaking` | | `legacy.temperature_controlling.TemperatureController` | set temperature, wait for temperature, deactivate | @@ -132,7 +133,8 @@ Detailed operation references: - [Machine lifecycle](event-bus/machine-lifecycle.md) - [Incubator](event-bus/incubator.md) - [LiquidHandler](event-bus/liquid-handler.md) -- [Shaker and temperature controller](event-bus/thermal-and-shaking.md) +- [Shaker and environmental controllers](event-bus/thermal-and-shaking.md) +- [HighRes sample storage](../high_res/sample-storage/events.md) - [VSpin centrifuge and Access2 loader](../agilent/vspin/events.md) - [Diagnostic transports](event-bus/diagnostic-transports.md) - [Canonical schema for every operation above](../../contributor_guide/event-schemas.md) @@ -151,8 +153,8 @@ event-bus/diagnostic-transports ### Incubator -`incubator.fetch_plate` and `incubator.take_in_plate` include `device`, the moved plate in -`resources`, and physical `source` and `destination` resource references. +`incubator.fetch_plate`, `incubator.take_in_plate`, and `incubator.transfer_plate` include `device`, +the moved plate in `resources`, and physical `source` and `destination` resource references. ### LiquidHandler @@ -162,11 +164,13 @@ operated resources plus `liquid_operations`, one record per channel, with `chann `resource`, optional owning `plate`, and `volume`. Tip events similarly include direct tip locations and per-channel `tip_operations`. -### Shaker and TemperatureController +### Shaker and environmental controllers Shaker events include `speed_rpm` and optional `duration`. Temperature-controller events include -`target_temperature` where applicable. Both frontends are `ResourceHolder`s: when a -resource is loaded at operation start, it is included as the direct resource in `resources`. +`target_temperature` where applicable. The legacy shaker and temperature frontends are +`ResourceHolder`s: when a resource is loaded at operation start, it is included as the direct +resource in `resources`. HighRes sample-store humidity and gas targets are fractions and use an +empty `resources` list because the controller acts on the store environment rather than one plate. ### Brooks PreciseFlex diff --git a/docs/user_guide/machine-agnostic-features/event-bus/incubator.md b/docs/user_guide/machine-agnostic-features/event-bus/incubator.md index e741d243e16..a023ade1362 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus/incubator.md +++ b/docs/user_guide/machine-agnostic-features/event-bus/incubator.md @@ -1,12 +1,18 @@ # Incubator events -The instrumented `legacy.storage.Incubator` frontend emits the following semantic operations: +The instrumented `legacy.storage.Incubator` and HighRes sample-storage frontends emit the following +semantic operations where their public APIs support them: | Operation | Lifecycle events | Primary fields | | --- | --- | --- | | `incubator.fetch_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | | `incubator.take_in_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | +| `incubator.transfer_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | `resources` contains the direct moved plate. `source` and `destination` identify the relevant PLR holders, such as a storage site and loading tray. The completed event can reflect the plate's post-operation resource assignment. + +`incubator.transfer_plate` describes a direct move between two transfer nests or other incubator +endpoints. The HighRes-specific environmental events are documented in its +[sample-storage event reference](../../high_res/sample-storage/events.md). diff --git a/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md b/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md index f025a08d912..20e6abe3b4f 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md +++ b/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md @@ -1,4 +1,4 @@ -# Shaker and temperature-controller events +# Shaker and environmental-controller events Each listed semantic operation emits `started`, `completed`, or `failed` lifecycle records. @@ -11,13 +11,16 @@ The instrumented `legacy.shaking.Shaker` frontend emits: | `shaker.shake` | `device`, loaded `resources`, `speed_rpm`, optional `duration` | | `shaker.stop_shaking` | `device`, loaded `resources` | -## TemperatureController +## Temperature controllers -The instrumented `legacy.temperature_controlling.TemperatureController` frontend emits: +Instrumented temperature-control frontends emit the operations their public APIs support. The +legacy `TemperatureController` does not have a separate activation method; direct frontends such as +the HighRes sample stores do. | Operation | Primary fields | | --- | --- | | `temperature_controller.set_temperature` | `device`, loaded `resources`, `target_temperature`, `passive` | +| `temperature_controller.activate` | `device`, loaded `resources` | | `temperature_controller.wait_for_temperature` | `device`, loaded `resources`, `target_temperature`, `timeout`, `tolerance`; completed event adds `current_temperature` | | `temperature_controller.hold_temperature` | `device`, loaded `resources`, `duration`, configured `target_temperature` when known | | `temperature_controller.deactivate` | `device`, loaded `resources`, configured `target_temperature` when known | @@ -37,3 +40,24 @@ surrounding deck state. New direct temperature-controller frontends, such as vendor-specific Inheco frontends, should implement this semantic EventBus contract at their own public API boundary. They do not need to inherit from the legacy `TemperatureController` class. + +## Humidity and gas controllers + +Environmental-control frontends use the same set/activate/deactivate lifecycle for humidity, CO2, +and O2. Targets are fractions: `0.90` means 90% RH and `0.05` means 5% gas concentration. + +| Operation | Primary fields | +| --- | --- | +| `humidity_controller.set_humidity` | `device`, `resources`, `target_humidity` | +| `humidity_controller.activate` | `device`, `resources` | +| `humidity_controller.deactivate` | `device`, `resources` | +| `co2_controller.set_co2` | `device`, `resources`, `target_co2` | +| `co2_controller.activate` | `device`, `resources` | +| `co2_controller.deactivate` | `device`, `resources` | +| `o2_controller.set_o2` | `device`, `resources`, `target_o2` | +| `o2_controller.activate` | `device`, `resources` | +| `o2_controller.deactivate` | `device`, `resources` | + +The HighRes sample stores currently emit these operations for their installed controllable +channels. See the [device event reference](../../high_res/sample-storage/events.md) for model +coverage. diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index a184fda7b92..e4747afefac 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -7,6 +7,7 @@ from pylabrobot.events import event_operation, resource_reference from pylabrobot.io.socket import Socket from pylabrobot.resources import ( + Coordinate, Plate, PlateCarrier, PlateHolder, @@ -14,6 +15,8 @@ ResourceNotFoundError, Rotation, ) +from pylabrobot.resources.barcode import Barcode +from pylabrobot.serializer import deserialize from ..stackers import high_res_stacker from .environment import EnvironmentControl @@ -166,8 +169,11 @@ def __init__( read_timeout=read_timeout, write_timeout=read_timeout, ) + self._host = host + self._port = port self._read_timeout = read_timeout self._motion_timeout = motion_timeout + self._transport_invalidated = False self._command_lock = asyncio.Lock() # A command lock prevents protocol responses from interleaving, but a plate # transfer also includes resource validation and bookkeeping on either side @@ -193,7 +199,203 @@ def unresolved_transfer(self) -> Optional[UnresolvedPlateTransfer]: return self._unresolved_transfer def serialize(self) -> dict: - raise NotImplementedError("HighRes sample store serialization is not implemented yet.") + """Serialize the device configuration and current resource inventory.""" + data = Resource.serialize(self) + data.update( + { + "host": self._host, + "port": self._port, + "read_timeout": self.read_timeout, + "motion_timeout": self.motion_timeout, + "racks_loaded": self._racks_loaded, + "rack_map": [ + { + "stacker": stacker, + "resource_name": rack.name, + "site_map": [ + {"spot": spot, "resource_name": site.name} for spot, site in rack.sites.items() + ], + } + for stacker, rack in self._racks_by_number.items() + ], + "nest_names": [nest.name for nest in self.nests], + } + ) + if self._unresolved_transfer is not None: + transfer = self._unresolved_transfer + data["unresolved_transfer"] = { + "plate": transfer.plate.serialize(), + "source_name": transfer.source.name, + "destination_name": transfer.destination.name, + "command": transfer.command, + "error_type": transfer.error_type, + "error_message": transfer.error_message, + } + return data + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False) -> "HighResSampleStorage": + """Restore a serialized sample store without connecting to hardware.""" + data_copy = data.copy() + data_copy.pop("type", None) + data_copy.pop("parent_name", None) + children_data = data_copy.pop("children", []) + location_data = data_copy.pop("location", None) + rotation_data = data_copy.pop("rotation", None) + barcode_data = data_copy.pop("barcode", None) + preferred_pickup_location_data = data_copy.pop("preferred_pickup_location", None) + metadata = data_copy.pop("metadata", {}) + rack_map = data_copy.pop("rack_map", []) + nest_names = data_copy.pop("nest_names", []) + racks_loaded = data_copy.pop("racks_loaded", True) + unresolved_data = data_copy.pop("unresolved_transfer", None) + + if not isinstance(children_data, list): + raise TypeError("children must be a list") + if not isinstance(rack_map, list): + raise TypeError("rack_map must be a list") + if not isinstance(nest_names, list) or not all(isinstance(name, str) for name in nest_names): + raise TypeError("nest_names must be a list of strings") + if not isinstance(metadata, dict): + raise TypeError("metadata must be a dict") + + child_records: Dict[str, Tuple[Resource, dict]] = {} + for child_data in children_data: + if not isinstance(child_data, dict): + raise TypeError("serialized child resources must be dictionaries") + child = Resource.deserialize(child_data, allow_marshal=allow_marshal) + child_records[child.name] = (child, child_data) + + racks: Dict[int, PlateCarrier] = {} + rack_names = set() + for entry in rack_map: + if not isinstance(entry, dict): + raise TypeError("rack_map entries must be dictionaries") + stacker = entry.get("stacker") + resource_name = entry.get("resource_name") + if not isinstance(stacker, int) or isinstance(stacker, bool): + raise TypeError("rack_map stacker numbers must be integers") + if not isinstance(resource_name, str): + raise TypeError("rack_map resource names must be strings") + try: + rack = child_records[resource_name][0] + except KeyError as exc: + raise ValueError(f"Serialized rack {resource_name!r} is missing from children.") from exc + if not isinstance(rack, PlateCarrier): + raise TypeError(f"Serialized rack {resource_name!r} is not a PlateCarrier.") + site_map = entry.get("site_map", []) + if not isinstance(site_map, list): + raise TypeError("rack_map site_map values must be lists") + sites_by_name = {site.name: site for site in rack.sites.values()} + restored_sites: Dict[int, PlateHolder] = {} + for site_entry in site_map: + if not isinstance(site_entry, dict): + raise TypeError("site_map entries must be dictionaries") + spot = site_entry.get("spot") + site_name = site_entry.get("resource_name") + if not isinstance(spot, int) or isinstance(spot, bool): + raise TypeError("site_map spots must be integers") + if not isinstance(site_name, str): + raise TypeError("site_map resource names must be strings") + try: + restored_sites[spot] = sites_by_name[site_name] + except KeyError as exc: + raise ValueError( + f"Serialized site {site_name!r} is missing from rack {resource_name!r}." + ) from exc + if len(restored_sites) != len(rack.sites): + raise ValueError(f"Serialized site mapping for rack {resource_name!r} is incomplete.") + rack.sites = restored_sites + racks[stacker] = rack + rack_names.add(resource_name) + + if not racks_loaded and racks: + raise ValueError("A store awaiting rack discovery cannot contain serialized racks.") + + rotation = ( + cast(Rotation, deserialize(rotation_data, allow_marshal=allow_marshal)) + if rotation_data is not None + else None + ) + store = cls( + host=data_copy.pop("host"), + name=data_copy.pop("name"), + racks=racks if racks_loaded else None, + size_x=data_copy.pop("size_x"), + size_y=data_copy.pop("size_y"), + size_z=data_copy.pop("size_z"), + rotation=rotation, + category=data_copy.pop("category", "plate_store"), + model=data_copy.pop("model", None), + port=data_copy.pop("port", 1000), + read_timeout=data_copy.pop("read_timeout", 30.0), + motion_timeout=data_copy.pop("motion_timeout", 240.0), + ) + if data_copy: + raise TypeError(f"Unexpected serialized sample-store fields: {sorted(data_copy)}") + + store.metadata = metadata.copy() + if barcode_data is not None: + if not isinstance(barcode_data, dict): + raise TypeError("barcode must be a dict") + store.barcode = Barcode(**barcode_data) + if preferred_pickup_location_data is not None: + store.preferred_pickup_location = cast( + Coordinate, + deserialize(preferred_pickup_location_data, allow_marshal=allow_marshal), + ) + if location_data is not None: + store.location = cast(Coordinate, deserialize(location_data, allow_marshal=allow_marshal)) + + used_child_names = set(rack_names) + for nest_name in nest_names: + try: + nest, _ = child_records[nest_name] + except KeyError as exc: + raise ValueError(f"Serialized nest {nest_name!r} is missing from children.") from exc + if not isinstance(nest, PlateHolder): + raise TypeError(f"Serialized nest {nest_name!r} is not a PlateHolder.") + store.assign_child_resource(nest, location=None) + store.nests.append(nest) + used_child_names.add(nest_name) + + for child_name, (child, child_data) in child_records.items(): + if child_name in used_child_names: + continue + child_location_data = child_data.get("location") + child_location = ( + cast(Coordinate, deserialize(child_location_data, allow_marshal=allow_marshal)) + if child_location_data is not None + else None + ) + store.assign_child_resource(child, location=child_location) + + if unresolved_data is not None: + if not isinstance(unresolved_data, dict): + raise TypeError("unresolved_transfer must be a dict") + plate_data = unresolved_data["plate"] + if not isinstance(plate_data, dict) or not isinstance(plate_data.get("name"), str): + raise TypeError("unresolved_transfer plate must be a serialized resource") + try: + plate_resource = store.get_resource(plate_data["name"]) + except ResourceNotFoundError: + plate_resource = Resource.deserialize(plate_data, allow_marshal=allow_marshal) + source = store.get_resource(unresolved_data["source_name"]) + destination = store.get_resource(unresolved_data["destination_name"]) + if not isinstance(plate_resource, Plate): + raise TypeError("unresolved_transfer plate is not a Plate") + if not isinstance(source, PlateHolder) or not isinstance(destination, PlateHolder): + raise TypeError("unresolved_transfer endpoints must be PlateHolders") + store._unresolved_transfer = UnresolvedPlateTransfer( + plate=plate_resource, + source=source, + destination=destination, + command=unresolved_data["command"], + error_type=unresolved_data["error_type"], + error_message=unresolved_data["error_message"], + ) + + return store # --- lifecycle ------------------------------------------------------------ @@ -201,9 +403,11 @@ async def setup(self, home: bool = False) -> None: if self._verification_warning is not None: logger.warning("%s", self._verification_warning) await self.io.setup() + self._transport_invalidated = False try: await self._setup_connected(home=home) except BaseException: + self._transport_invalidated = True await self.io.stop() raise @@ -286,9 +490,12 @@ async def _load_racks_from_device(self) -> None: self._racks_loaded = True logger.info("Loaded %d configured stackers for %s", len(self._racks_by_number), self.name) - async def stop(self): + async def stop(self) -> None: logger.info("Stopping %s", self.name) - await self.io.stop() + try: + await self.io.stop() + finally: + self._transport_invalidated = True # --- transport ------------------------------------------------------------ @@ -296,7 +503,7 @@ 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]: + 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). @@ -356,6 +563,7 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L logger.exception( "Invalidating %s connection after incomplete command %r", self.name, command ) + self._transport_invalidated = True try: await self.io.stop() except BaseException: @@ -373,6 +581,14 @@ async def send_command(self, command: str, timeout: Optional[float] = None) -> L raise HighResSampleStorageProtocolError(command, line, "unknown completion status") return data_lines + async def _reconnect_invalidated_transport(self) -> None: + """Reconnect after an incomplete response left the command stream unusable.""" + if not self._transport_invalidated: + return + logger.info("Reconnecting to %s before transfer reconciliation", self.name) + await self.io.setup() + self._transport_invalidated = False + @staticmethod def _parse_envelope(token: str, line: str, command: str) -> Tuple[str, str]: prefix = f"{token} " @@ -388,7 +604,7 @@ def _parse_envelope(token: str, line: str, command: str) -> Tuple[str, str]: # --- shared device queries ------------------------------------------------ async def request_version(self) -> VersionInfo: - raw = parse_kv(await self.send_command("version")) + raw = parse_kv(await self._send_command("version")) return VersionInfo( product_name=raw.get("Product Name"), serial_number=raw.get("Serial Number"), @@ -405,7 +621,7 @@ async def request_environment(self) -> Dict[str, EnvironmentParameter]: temperature and humidity controls. """ out: Dict[str, EnvironmentParameter] = {} - for line in await self.send_command("environmentstatus"): + for line in await self._send_command("environmentstatus"): if ":" not in line: continue name, _, rest = line.partition(":") @@ -432,7 +648,7 @@ def _opt(i: int, parts=parts) -> Optional[float]: 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.send_command("status")).items(): + for key, value in parse_kv(await self._send_command("status")).items(): try: out[key] = float(value) except ValueError: @@ -440,13 +656,13 @@ async def request_axis_positions(self) -> Dict[str, float]: return out async def request_is_homed(self) -> bool: - lines = await self.send_command("homedstatus") + lines = await self._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 parse_kv(await self.send_command("doorstatus")).items(): + for name, value in parse_kv(await self._send_command("doorstatus")).items(): state = value.lower() doors[name] = cast(DoorState, state) if state in DOOR_STATES else "unknown" return doors @@ -454,7 +670,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.send_command("neststatus")).items(): + for key, value in parse_kv(await self._send_command("neststatus")).items(): try: nest = int(key) except ValueError: @@ -467,7 +683,7 @@ async def request_nest_status(self) -> Dict[int, NestState]: async def request_spatula_is_holding(self) -> bool: """Whether a plate is currently held on the spatula (``platestatus``).""" - lines = await self.send_command("platestatus") + lines = await self._send_command("platestatus") return not any("NO_PLATE" in line for line in lines) async def request_nest_is_holding(self, nest: int) -> bool: @@ -501,7 +717,7 @@ async def request_stacker_dimensions(self) -> List[StackerDimensions]: """Parse ``getstackerdimensions`` (``: ``).""" dims: List[StackerDimensions] = [] - for line in await self.send_command("getstackerdimensions"): + for line in await self._send_command("getstackerdimensions"): key, _, rest = line.partition(":") try: stacker = int(key) @@ -521,7 +737,7 @@ async def request_stacker_dimensions(self) -> List[StackerDimensions]: async def request_settings(self) -> HighResSampleStorageSettings: """Read the device's full settings file (``NAME = value`` pairs) into a frozen :class:`HighResSampleStorageSettings`.""" - lines = await self.send_command("settings", timeout=self.read_timeout) + lines = await self._send_command("settings", timeout=self.read_timeout) return HighResSampleStorageSettings.from_lines(lines) async def request_stacker_barcodes( @@ -559,7 +775,7 @@ async def request_stacker_barcodes( 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._send_command(command, timeout=self.motion_timeout) # --- motion --------------------------------------------------------------- @@ -570,7 +786,7 @@ async def home(self): if await self.request_spatula_is_holding(): raise RuntimeError("Cannot home while the spatula reports that it is holding a plate.") logger.info("Homing %s", self.name) - await self.send_command("home", timeout=self.motion_timeout) + await self._send_command("home", timeout=self.motion_timeout) logger.info("Homed %s", self.name) async def request_is_parked(self) -> bool: @@ -614,7 +830,7 @@ async def recover(self) -> bool: for attempt in range(1, 4): for command in ("enable", "spatulaout"): try: - await self.send_command(command, timeout=self.motion_timeout) + await self._send_command(command, timeout=self.motion_timeout) except HighResSampleStorageError as exc: logger.warning( "Recovery attempt %d: %s failed on %s: %s", @@ -624,7 +840,7 @@ async def recover(self) -> bool: exc, ) try: - await self.send_command("home", timeout=self.motion_timeout) + await self._send_command("home", timeout=self.motion_timeout) except HighResSampleStorageError as exc: logger.warning("Recovery attempt %d: home failed on %s: %s", attempt, self.name, exc) if await self.request_is_parked(): @@ -659,7 +875,7 @@ async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = Tru nest, ) try: - await self.send_command(command, timeout=self.motion_timeout) + await self._send_command(command, timeout=self.motion_timeout) except HighResSampleStorageError as exc: if left_unsafe(exc.error_lines) or not await self.request_is_homed(): logger.error("Pick left %s unsafe: %s", self.name, exc) @@ -690,7 +906,7 @@ async def _place(self, stacker: int, slot: int, nest: int, close_door: bool = Tr slot, ) try: - await self.send_command(command, timeout=self.motion_timeout) + await self._send_command(command, timeout=self.motion_timeout) except HighResSampleStorageError as exc: if left_unsafe(exc.error_lines) or not await self.request_is_homed(): logger.error("Place left %s unsafe: %s", self.name, exc) @@ -738,7 +954,7 @@ async def open_all_doors(self) -> None: """ try: logger.info("Opening robot doors on %s", self.name) - await self.send_command("openalldoors", timeout=self.motion_timeout) + await self._send_command("openalldoors", timeout=self.motion_timeout) except HighResSampleStorageError: logger.warning("Open-all-doors returned an error on %s; checking door sensors", self.name) if await self._wait_for_robot_doors(target="open", moving="opening"): @@ -754,7 +970,7 @@ async def close_all_doors(self) -> None: """ try: logger.info("Closing robot doors on %s", self.name) - await self.send_command("closealldoors", timeout=self.motion_timeout) + await self._send_command("closealldoors", timeout=self.motion_timeout) except HighResSampleStorageError: logger.warning("Close-all-doors returned an error on %s; checking door sensors", self.name) if await self._wait_for_robot_doors(target="closed", moving="closing"): @@ -769,7 +985,7 @@ async def clear_abort(self) -> None: abort, so this is recovery for device- or externally-initiated aborts. """ logger.info("Clearing abort state on %s", self.name) - await self.send_command("clearabort") + await self._send_command("clearabort") # --- plate retrieval ------------------------------------------------------- @@ -910,6 +1126,8 @@ async def resolve_unresolved_transfer( f"location must be 'source', 'destination', or 'unassigned'; got {location!r}." ) + await self._reconnect_invalidated_transport() + target = ( transfer.source if location == "source" @@ -1112,7 +1330,7 @@ async def transfer_plate_between_nests( source=source, destination=destination, command=command, - move=lambda: self.send_command(command, timeout=self.motion_timeout), + move=lambda: self._send_command(command, timeout=self.motion_timeout), ) async def _store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: diff --git a/pylabrobot/high_res/sample_storage/driver/environment.py b/pylabrobot/high_res/sample_storage/driver/environment.py index 3401299a8f8..e2d3923b51c 100644 --- a/pylabrobot/high_res/sample_storage/driver/environment.py +++ b/pylabrobot/high_res/sample_storage/driver/environment.py @@ -94,13 +94,13 @@ async def _set_percentage(self, channel: str, value: float) -> None: raise ValueError(f"{channel} must be between 0 and 1.") await self._require_controllable_channel(channel) logger.info("Setting %s %s target to %g", self._driver.name, channel, value) - await self._driver.send_command(f"environmentset {channel} {value * 100:g}") + await self._driver._send_command(f"environmentset {channel} {value * 100:g}") async def _set_control_enabled(self, channel: str, enabled: bool) -> None: await self._require_controllable_channel(channel) action = "enable" if enabled else "disable" logger.info("Setting %s %s control to %s", self._driver.name, channel, action) - await self._driver.send_command(f"environment {action} {channel.lower()}") + await self._driver._send_command(f"environment {action} {channel.lower()}") @property def supports_active_cooling(self) -> bool: @@ -140,7 +140,7 @@ async def set_temperature(self, temperature: float) -> None: f"for {self._driver.model}." ) logger.info("Setting %s temperature target to %g C", self._driver.name, temperature) - await self._driver.send_command(f"environmentset TEMP {temperature}") + await self._driver._send_command(f"environmentset TEMP {temperature}") @evented_operation("temperature_controller.activate", _control_event_context) async def start_temperature_control(self) -> None: diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index 6c49d682d3d..54de6e25d47 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -1,5 +1,6 @@ import asyncio import inspect +import json import unittest from typing import Dict, List from unittest.mock import AsyncMock @@ -16,7 +17,7 @@ ) from pylabrobot.high_res.sample_storage.driver.models import get_model_info from pylabrobot.high_res.sample_storage.driver.settings import HighResSampleStorageSettings -from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Well +from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Resource, Well # Real responses captured from a SteriStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. @@ -268,7 +269,7 @@ async def test_unverified_models_warn_during_setup_without_hiding_signature(self self.assertIn("host: str", str(inspect.signature(model_class))) async def test_send_command_strips_ack_and_completion(self): - data = await self.driver.send_command("neststatus") + data = await self.driver._send_command("neststatus") self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) self.assertEqual(self.socket.written, ["neststatus"]) @@ -276,13 +277,13 @@ async def test_send_command_requires_acknowledgement_first(self): self.socket.captures["bad"] = ["OK! bad 1"] with self.assertRaisesRegex(HighResSampleStorageProtocolError, "expected ACK! envelope"): - await self.driver.send_command("bad") + await self.driver._send_command("bad") async def test_send_command_validates_acknowledged_command(self): self.socket.captures["bad"] = ["ACK! other 1", "OK! other 1"] with self.assertRaisesRegex(HighResSampleStorageProtocolError, "ACK echoed command 'other'"): - await self.driver.send_command("bad") + await self.driver._send_command("bad") async def test_send_command_validates_completion_command(self): self.socket.captures["bad"] = ["ACK! bad 1", "OK! other 1"] @@ -290,26 +291,26 @@ async def test_send_command_validates_completion_command(self): with self.assertRaisesRegex( HighResSampleStorageProtocolError, "completion echoed command 'other'" ): - await self.driver.send_command("bad") + await self.driver._send_command("bad") async def test_send_command_validates_completion_command_id(self): self.socket.captures["bad"] = ["ACK! bad 1", "OK! bad 2"] with self.assertRaisesRegex(HighResSampleStorageProtocolError, "does not match ACK ID '1'"): - await self.driver.send_command("bad") + await self.driver._send_command("bad") async def test_send_command_rejects_duplicate_acknowledgement(self): self.socket.captures["bad"] = ["ACK! bad 1", "ACK! bad 1", "OK! bad 1"] with self.assertRaisesRegex(HighResSampleStorageProtocolError, "received a second ACK"): - await self.driver.send_command("bad") + await self.driver._send_command("bad") async def test_send_command_closes_transport_after_response_timeout(self): socket = TimeoutAfterAckSocket({"slow": ["ACK! slow 1", "OK! slow 1"]}, timeout_command="slow") self.driver.io = socket # type: ignore[assignment] with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): - await self.driver.send_command("slow") + await self.driver._send_command("slow") self.assertEqual(socket.stop_calls, 1) @@ -675,9 +676,26 @@ async def test_tray_maps_to_nest(self): with self.assertRaises(ValueError): self.retrieval._nest_for_tray(2) - def test_serialize_is_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.driver.serialize() + async def test_serialization_round_trip_preserves_configuration_and_nests(self): + await self.driver.setup() + serialized = self.driver.serialize() + restored = Resource.deserialize(serialized) + + self.assertIsInstance(restored, SteriStore) + assert isinstance(restored, SteriStore) + self.assertEqual(restored.serialize(), serialized) + self.assertEqual(restored.read_timeout, self.driver.read_timeout) + self.assertEqual(restored.motion_timeout, self.driver.motion_timeout) + + def test_serialization_preserves_pending_rack_discovery(self): + driver = SteriStore(host="192.0.2.1", name="discovery_store") + + restored = Resource.deserialize(json.loads(json.dumps(driver.serialize()))) + + self.assertIsInstance(restored, SteriStore) + assert isinstance(restored, SteriStore) + self.assertFalse(restored._racks_loaded) + self.assertEqual(restored.racks, []) class HighResSampleStorageBookkeepingTests(unittest.IsolatedAsyncioTestCase): @@ -772,6 +790,13 @@ def test_explicit_carrier_spots_define_physical_slots(self): self.assertEqual(driver._locate(first_inserted), (7, 8)) self.assertEqual(driver._locate(second_inserted), (7, 3)) + restored = Resource.deserialize(json.loads(json.dumps(driver.serialize()))) + self.assertIsInstance(restored, HighResSampleStorage) + assert isinstance(restored, HighResSampleStorage) + restored_rack = restored._racks_by_number[7] + self.assertEqual(restored._locate(restored_rack.sites[7]), (7, 8)) + self.assertEqual(restored._locate(restored_rack.sites[2]), (7, 3)) + def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): short = PlateHolder(name="short", size_x=127.76, size_y=85.48, size_z=16, pedestal_size_z=0) tall = PlateHolder(name="tall", size_x=127.76, size_y=85.48, size_z=18, pedestal_size_z=0) @@ -829,6 +854,23 @@ def test_inventory_queries_use_resource_tree(self): self.assertEqual(self.driver.get_num_free_sites(), 0) self.assertIs(self.driver.get_site_by_plate_name("plate"), self.site) + def test_serialization_round_trip_preserves_rack_mapping_and_plate_inventory(self): + serialized = json.loads(json.dumps(self.driver.serialize())) + restored = Resource.deserialize(serialized) + + self.assertIsInstance(restored, HighResSampleStorage) + assert isinstance(restored, HighResSampleStorage) + self.assertEqual(list(restored._racks_by_number), [1]) + restored_site = restored.get_site_by_plate_name("plate") + self.assertEqual(restored._locate(restored_site), (1, 1)) + self.assertEqual( + [nest.name for nest in restored.nests], + [ + "sample_store_nest_1", + "sample_store_nest_2", + ], + ) + async def test_take_in_plate_moves_nest_resource_to_selected_site(self): self.plate.unassign() self.driver.nests[0].assign_child_resource(self.plate) @@ -967,6 +1009,13 @@ async def test_timeout_after_pick_ack_records_unresolved_transfer(self): self.assertIs(self.plate.parent, self.site) self.assertEqual(socket.stop_calls, 1) + result = await self.driver.resolve_unresolved_transfer("source") + + self.assertIs(result, self.plate) + self.assertEqual(socket.setup_calls, 1) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(socket.written[-2:], ["platestatus", "neststatus"]) + async def test_cancelled_pick_records_unresolved_transfer(self): started = asyncio.Event() @@ -1132,11 +1181,22 @@ async def test_failed_store_records_unresolved_transfer_and_blocks_another_move( self.assertEqual(transfer.command, "place 1 1 1") self.assertEqual(transfer.error_type, "HighResSampleStorageError") + restored = Resource.deserialize(json.loads(json.dumps(self.driver.serialize()))) + self.assertIsInstance(restored, HighResSampleStorage) + assert isinstance(restored, HighResSampleStorage) + restored_transfer = restored.unresolved_transfer + self.assertIsNotNone(restored_transfer) + assert restored_transfer is not None + self.assertEqual(restored_transfer.plate.name, "plate") + self.assertEqual(restored_transfer.source.name, "sample_store_nest_1") + self.assertEqual(restored_transfer.destination.name, "site_1") + self.assertEqual(restored_transfer.command, "place 1 1 1") + written = list(self.socket.written) with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): await self.driver.store_plate(self.plate, self.site, tray_index=0) with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): - await self.driver.send_command("nesttransfer 1 2") + await self.driver._send_command("nesttransfer 1 2") self.assertEqual(self.socket.written, written) async def test_resolve_unresolved_store_to_source_uses_live_nest_sensor(self): diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index a1296230241..4804a7cdb68 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -958,6 +958,8 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: if subclass is None: raise ValueError(f'Could not find subclass with name "{data["type"]}"') assert issubclass(subclass, cls) + if subclass is not cls: + return cast(Self, subclass.deserialize(data, allow_marshal=allow_marshal)) for key in [ "type", From f1e0c23df87db8a4e84f6b84e3d898935ad5f040 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 26 Aug 2026 15:40:01 -0700 Subject: [PATCH 7/8] fix(high_res): harden sample store recovery --- .../high_res/sample-storage/index.md | 5 +- .../high_res/sample_storage/driver/driver.py | 221 +----------------- .../sample_storage/tests/driver_tests.py | 72 ++---- .../sample_storage/tests/recovery_tests.py | 45 +++- pylabrobot/resources/resource.py | 2 - 5 files changed, 72 insertions(+), 273 deletions(-) diff --git a/docs/user_guide/high_res/sample-storage/index.md b/docs/user_guide/high_res/sample-storage/index.md index e128dd6b024..1ec78663220 100644 --- a/docs/user_guide/high_res/sample-storage/index.md +++ b/docs/user_guide/high_res/sample-storage/index.md @@ -136,8 +136,9 @@ axes are retracted. `recover()` refuses to move when the spatula sensor reports the plate's physical support must be inspected before a safe recovery path can be chosen. If a transfer response is interrupted or otherwise ambiguous, the driver keeps the last confirmed -resource assignment in place, records `store.unresolved_transfer`, and blocks additional plate -motion. Inspect the machine, then reconcile the observed result without further motion: +resource assignment in place, records `store.unresolved_transfer`, and blocks further plate moves, +homing, and barcode scans. Inspect the machine, recover it when necessary, then reconcile the +observed result: ```python await store.resolve_unresolved_transfer("source") diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index e4747afefac..ab8de243c90 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -7,7 +7,6 @@ from pylabrobot.events import event_operation, resource_reference from pylabrobot.io.socket import Socket from pylabrobot.resources import ( - Coordinate, Plate, PlateCarrier, PlateHolder, @@ -15,8 +14,6 @@ ResourceNotFoundError, Rotation, ) -from pylabrobot.resources.barcode import Barcode -from pylabrobot.serializer import deserialize from ..stackers import high_res_stacker from .environment import EnvironmentControl @@ -79,9 +76,10 @@ class HighResSampleStorage(Resource): with a 0-based ``tray_index``. If a plate move is interrupted without a definitive outcome, the driver - exposes it through :attr:`unresolved_transfer` and blocks further plate moves - until :meth:`resolve_unresolved_transfer` reconciles the physical and PLR - locations. + exposes it through :attr:`unresolved_transfer` and blocks further plate moves, + homing, and barcode scans until :meth:`resolve_unresolved_transfer` reconciles + the physical and PLR locations. :meth:`recover` remains available to park an + unsafe mechanism before reconciliation. Subclasses set :attr:`_model_name`; callers can override it with ``model``. This configured model selects model-specific behavior. The product name @@ -169,8 +167,6 @@ def __init__( read_timeout=read_timeout, write_timeout=read_timeout, ) - self._host = host - self._port = port self._read_timeout = read_timeout self._motion_timeout = motion_timeout self._transport_invalidated = False @@ -199,203 +195,7 @@ def unresolved_transfer(self) -> Optional[UnresolvedPlateTransfer]: return self._unresolved_transfer def serialize(self) -> dict: - """Serialize the device configuration and current resource inventory.""" - data = Resource.serialize(self) - data.update( - { - "host": self._host, - "port": self._port, - "read_timeout": self.read_timeout, - "motion_timeout": self.motion_timeout, - "racks_loaded": self._racks_loaded, - "rack_map": [ - { - "stacker": stacker, - "resource_name": rack.name, - "site_map": [ - {"spot": spot, "resource_name": site.name} for spot, site in rack.sites.items() - ], - } - for stacker, rack in self._racks_by_number.items() - ], - "nest_names": [nest.name for nest in self.nests], - } - ) - if self._unresolved_transfer is not None: - transfer = self._unresolved_transfer - data["unresolved_transfer"] = { - "plate": transfer.plate.serialize(), - "source_name": transfer.source.name, - "destination_name": transfer.destination.name, - "command": transfer.command, - "error_type": transfer.error_type, - "error_message": transfer.error_message, - } - return data - - @classmethod - def deserialize(cls, data: dict, allow_marshal: bool = False) -> "HighResSampleStorage": - """Restore a serialized sample store without connecting to hardware.""" - data_copy = data.copy() - data_copy.pop("type", None) - data_copy.pop("parent_name", None) - children_data = data_copy.pop("children", []) - location_data = data_copy.pop("location", None) - rotation_data = data_copy.pop("rotation", None) - barcode_data = data_copy.pop("barcode", None) - preferred_pickup_location_data = data_copy.pop("preferred_pickup_location", None) - metadata = data_copy.pop("metadata", {}) - rack_map = data_copy.pop("rack_map", []) - nest_names = data_copy.pop("nest_names", []) - racks_loaded = data_copy.pop("racks_loaded", True) - unresolved_data = data_copy.pop("unresolved_transfer", None) - - if not isinstance(children_data, list): - raise TypeError("children must be a list") - if not isinstance(rack_map, list): - raise TypeError("rack_map must be a list") - if not isinstance(nest_names, list) or not all(isinstance(name, str) for name in nest_names): - raise TypeError("nest_names must be a list of strings") - if not isinstance(metadata, dict): - raise TypeError("metadata must be a dict") - - child_records: Dict[str, Tuple[Resource, dict]] = {} - for child_data in children_data: - if not isinstance(child_data, dict): - raise TypeError("serialized child resources must be dictionaries") - child = Resource.deserialize(child_data, allow_marshal=allow_marshal) - child_records[child.name] = (child, child_data) - - racks: Dict[int, PlateCarrier] = {} - rack_names = set() - for entry in rack_map: - if not isinstance(entry, dict): - raise TypeError("rack_map entries must be dictionaries") - stacker = entry.get("stacker") - resource_name = entry.get("resource_name") - if not isinstance(stacker, int) or isinstance(stacker, bool): - raise TypeError("rack_map stacker numbers must be integers") - if not isinstance(resource_name, str): - raise TypeError("rack_map resource names must be strings") - try: - rack = child_records[resource_name][0] - except KeyError as exc: - raise ValueError(f"Serialized rack {resource_name!r} is missing from children.") from exc - if not isinstance(rack, PlateCarrier): - raise TypeError(f"Serialized rack {resource_name!r} is not a PlateCarrier.") - site_map = entry.get("site_map", []) - if not isinstance(site_map, list): - raise TypeError("rack_map site_map values must be lists") - sites_by_name = {site.name: site for site in rack.sites.values()} - restored_sites: Dict[int, PlateHolder] = {} - for site_entry in site_map: - if not isinstance(site_entry, dict): - raise TypeError("site_map entries must be dictionaries") - spot = site_entry.get("spot") - site_name = site_entry.get("resource_name") - if not isinstance(spot, int) or isinstance(spot, bool): - raise TypeError("site_map spots must be integers") - if not isinstance(site_name, str): - raise TypeError("site_map resource names must be strings") - try: - restored_sites[spot] = sites_by_name[site_name] - except KeyError as exc: - raise ValueError( - f"Serialized site {site_name!r} is missing from rack {resource_name!r}." - ) from exc - if len(restored_sites) != len(rack.sites): - raise ValueError(f"Serialized site mapping for rack {resource_name!r} is incomplete.") - rack.sites = restored_sites - racks[stacker] = rack - rack_names.add(resource_name) - - if not racks_loaded and racks: - raise ValueError("A store awaiting rack discovery cannot contain serialized racks.") - - rotation = ( - cast(Rotation, deserialize(rotation_data, allow_marshal=allow_marshal)) - if rotation_data is not None - else None - ) - store = cls( - host=data_copy.pop("host"), - name=data_copy.pop("name"), - racks=racks if racks_loaded else None, - size_x=data_copy.pop("size_x"), - size_y=data_copy.pop("size_y"), - size_z=data_copy.pop("size_z"), - rotation=rotation, - category=data_copy.pop("category", "plate_store"), - model=data_copy.pop("model", None), - port=data_copy.pop("port", 1000), - read_timeout=data_copy.pop("read_timeout", 30.0), - motion_timeout=data_copy.pop("motion_timeout", 240.0), - ) - if data_copy: - raise TypeError(f"Unexpected serialized sample-store fields: {sorted(data_copy)}") - - store.metadata = metadata.copy() - if barcode_data is not None: - if not isinstance(barcode_data, dict): - raise TypeError("barcode must be a dict") - store.barcode = Barcode(**barcode_data) - if preferred_pickup_location_data is not None: - store.preferred_pickup_location = cast( - Coordinate, - deserialize(preferred_pickup_location_data, allow_marshal=allow_marshal), - ) - if location_data is not None: - store.location = cast(Coordinate, deserialize(location_data, allow_marshal=allow_marshal)) - - used_child_names = set(rack_names) - for nest_name in nest_names: - try: - nest, _ = child_records[nest_name] - except KeyError as exc: - raise ValueError(f"Serialized nest {nest_name!r} is missing from children.") from exc - if not isinstance(nest, PlateHolder): - raise TypeError(f"Serialized nest {nest_name!r} is not a PlateHolder.") - store.assign_child_resource(nest, location=None) - store.nests.append(nest) - used_child_names.add(nest_name) - - for child_name, (child, child_data) in child_records.items(): - if child_name in used_child_names: - continue - child_location_data = child_data.get("location") - child_location = ( - cast(Coordinate, deserialize(child_location_data, allow_marshal=allow_marshal)) - if child_location_data is not None - else None - ) - store.assign_child_resource(child, location=child_location) - - if unresolved_data is not None: - if not isinstance(unresolved_data, dict): - raise TypeError("unresolved_transfer must be a dict") - plate_data = unresolved_data["plate"] - if not isinstance(plate_data, dict) or not isinstance(plate_data.get("name"), str): - raise TypeError("unresolved_transfer plate must be a serialized resource") - try: - plate_resource = store.get_resource(plate_data["name"]) - except ResourceNotFoundError: - plate_resource = Resource.deserialize(plate_data, allow_marshal=allow_marshal) - source = store.get_resource(unresolved_data["source_name"]) - destination = store.get_resource(unresolved_data["destination_name"]) - if not isinstance(plate_resource, Plate): - raise TypeError("unresolved_transfer plate is not a Plate") - if not isinstance(source, PlateHolder) or not isinstance(destination, PlateHolder): - raise TypeError("unresolved_transfer endpoints must be PlateHolders") - store._unresolved_transfer = UnresolvedPlateTransfer( - plate=plate_resource, - source=source, - destination=destination, - command=unresolved_data["command"], - error_type=unresolved_data["error_type"], - error_message=unresolved_data["error_message"], - ) - - return store + raise NotImplementedError("HighRes sample store serialization is not implemented yet.") # --- lifecycle ------------------------------------------------------------ @@ -761,6 +561,7 @@ async def request_stacker_barcodes( raise ValueError("slot cannot be specified when stacker is 'all'.") if slot is not None and slot < 1: raise ValueError("slot must be a positive integer.") + self._require_no_unresolved_transfer() occupied_nests = [ nest for nest, state in (await self.request_nest_status()).items() if state != "clear" @@ -783,6 +584,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:`HighResSampleStorageError` ("Unable to close all doors").""" + self._require_no_unresolved_transfer() if await self.request_spatula_is_holding(): raise RuntimeError("Cannot home while the spatula reports that it is holding a plate.") logger.info("Homing %s", self.name) @@ -863,8 +665,8 @@ async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = Tru 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:`request_is_homed`) to detect that case. + at a top slot, so the error stack and live axis positions are both used to + determine whether the machine is parked. """ command = f"pick {stacker} {slot} {nest}" logger.info( @@ -877,7 +679,7 @@ async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = Tru try: await self._send_command(command, timeout=self.motion_timeout) except HighResSampleStorageError as exc: - if left_unsafe(exc.error_lines) or not await self.request_is_homed(): + if left_unsafe(exc.error_lines) or not await self.request_is_parked(): logger.error("Pick left %s unsafe: %s", self.name, exc) raise HighResSampleStorageFault(command, exc.error_lines) from exc if any("no plate detected" in line.lower() for line in exc.error_lines): @@ -1036,7 +838,8 @@ def _require_no_unresolved_transfer(self) -> None: if transfer is not None: raise RuntimeError( f"Plate location is unresolved after transfer command {transfer.command!r}; " - "call resolve_unresolved_transfer() before another plate move." + "recover the machine if necessary, then call resolve_unresolved_transfer() " + "before another plate move, home, or barcode scan." ) def _record_unresolved_transfer( diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index 54de6e25d47..528303a22b8 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -1,6 +1,5 @@ import asyncio import inspect -import json import unittest from typing import Dict, List from unittest.mock import AsyncMock @@ -17,7 +16,7 @@ ) from pylabrobot.high_res.sample_storage.driver.models import get_model_info from pylabrobot.high_res.sample_storage.driver.settings import HighResSampleStorageSettings -from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Resource, Well +from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Well # Real responses captured from a SteriStore (firmware 3.0.0.119, serial # HRB-2209-35148) over the port-1000 remote-control server. @@ -33,6 +32,13 @@ "OK! version 1", ], "homedstatus": ["ACK! homedstatus 5", "not homed", "OK! homedstatus 5"], + "status": [ + "ACK! status 6", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 6", + ], "doorstatus": [ "ACK! doorstatus 17", "User Door: CLOSED", @@ -676,26 +682,9 @@ async def test_tray_maps_to_nest(self): with self.assertRaises(ValueError): self.retrieval._nest_for_tray(2) - async def test_serialization_round_trip_preserves_configuration_and_nests(self): - await self.driver.setup() - serialized = self.driver.serialize() - restored = Resource.deserialize(serialized) - - self.assertIsInstance(restored, SteriStore) - assert isinstance(restored, SteriStore) - self.assertEqual(restored.serialize(), serialized) - self.assertEqual(restored.read_timeout, self.driver.read_timeout) - self.assertEqual(restored.motion_timeout, self.driver.motion_timeout) - - def test_serialization_preserves_pending_rack_discovery(self): - driver = SteriStore(host="192.0.2.1", name="discovery_store") - - restored = Resource.deserialize(json.loads(json.dumps(driver.serialize()))) - - self.assertIsInstance(restored, SteriStore) - assert isinstance(restored, SteriStore) - self.assertFalse(restored._racks_loaded) - self.assertEqual(restored.racks, []) + def test_serialization_is_not_implemented(self): + with self.assertRaisesRegex(NotImplementedError, "serialization is not implemented"): + self.driver.serialize() class HighResSampleStorageBookkeepingTests(unittest.IsolatedAsyncioTestCase): @@ -790,13 +779,6 @@ def test_explicit_carrier_spots_define_physical_slots(self): self.assertEqual(driver._locate(first_inserted), (7, 8)) self.assertEqual(driver._locate(second_inserted), (7, 3)) - restored = Resource.deserialize(json.loads(json.dumps(driver.serialize()))) - self.assertIsInstance(restored, HighResSampleStorage) - assert isinstance(restored, HighResSampleStorage) - restored_rack = restored._racks_by_number[7] - self.assertEqual(restored._locate(restored_rack.sites[7]), (7, 8)) - self.assertEqual(restored._locate(restored_rack.sites[2]), (7, 3)) - def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): short = PlateHolder(name="short", size_x=127.76, size_y=85.48, size_z=16, pedestal_size_z=0) tall = PlateHolder(name="tall", size_x=127.76, size_y=85.48, size_z=18, pedestal_size_z=0) @@ -854,23 +836,6 @@ def test_inventory_queries_use_resource_tree(self): self.assertEqual(self.driver.get_num_free_sites(), 0) self.assertIs(self.driver.get_site_by_plate_name("plate"), self.site) - def test_serialization_round_trip_preserves_rack_mapping_and_plate_inventory(self): - serialized = json.loads(json.dumps(self.driver.serialize())) - restored = Resource.deserialize(serialized) - - self.assertIsInstance(restored, HighResSampleStorage) - assert isinstance(restored, HighResSampleStorage) - self.assertEqual(list(restored._racks_by_number), [1]) - restored_site = restored.get_site_by_plate_name("plate") - self.assertEqual(restored._locate(restored_site), (1, 1)) - self.assertEqual( - [nest.name for nest in restored.nests], - [ - "sample_store_nest_1", - "sample_store_nest_2", - ], - ) - async def test_take_in_plate_moves_nest_resource_to_selected_site(self): self.plate.unassign() self.driver.nests[0].assign_child_resource(self.plate) @@ -1181,22 +1146,15 @@ async def test_failed_store_records_unresolved_transfer_and_blocks_another_move( self.assertEqual(transfer.command, "place 1 1 1") self.assertEqual(transfer.error_type, "HighResSampleStorageError") - restored = Resource.deserialize(json.loads(json.dumps(self.driver.serialize()))) - self.assertIsInstance(restored, HighResSampleStorage) - assert isinstance(restored, HighResSampleStorage) - restored_transfer = restored.unresolved_transfer - self.assertIsNotNone(restored_transfer) - assert restored_transfer is not None - self.assertEqual(restored_transfer.plate.name, "plate") - self.assertEqual(restored_transfer.source.name, "sample_store_nest_1") - self.assertEqual(restored_transfer.destination.name, "site_1") - self.assertEqual(restored_transfer.command, "place 1 1 1") - written = list(self.socket.written) with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): await self.driver.store_plate(self.plate, self.site, tray_index=0) with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): await self.driver._send_command("nesttransfer 1 2") + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.home() + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.request_stacker_barcodes(1) self.assertEqual(self.socket.written, written) async def test_resolve_unresolved_store_to_source_uses_live_nest_sensor(self): diff --git a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py index 2118d87cc63..97b91de5bc0 100644 --- a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -45,8 +45,8 @@ def setUp(self): self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={}) self.retrieval = self.driver - 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). + async def test_empty_slot_pick_raises_plate_not_found_when_parked(self): + # The store reports "No plate detected" and stays parked (graceful empty). empty = [ "ACK! pick 5 12 1 50", "Error 1: (00:00:01) 50: No plate detected", @@ -56,13 +56,52 @@ async def test_empty_slot_pick_raises_plate_not_found_and_stays_homed(self): [ ("pick 5 12 1", empty), ("homedstatus", ["ACK! homedstatus 51", "homed", "OK! homedstatus 51"]), + ( + "status", + [ + "ACK! status 52", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 52", + ], + ), ] ) self.driver.io = sock # type: ignore[assignment] with self.assertRaises(PlateNotFoundError): await self.retrieval._pick(5, 12, 1) # classified by state, no recovery motion issued - self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus"]) + self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus", "status"]) + + async def test_empty_slot_pick_raises_fault_when_homed_but_extended(self): + 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"]), + ( + "status", + [ + "ACK! status 52", + "Carousel: 0.0", + "Y axis: 256.0", + "Z axis: 0.0", + "OK! status 52", + ], + ), + ] + ) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._pick(5, 12, 1) + + self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus", "status"]) 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 diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 4804a7cdb68..a1296230241 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -958,8 +958,6 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: if subclass is None: raise ValueError(f'Could not find subclass with name "{data["type"]}"') assert issubclass(subclass, cls) - if subclass is not cls: - return cast(Self, subclass.deserialize(data, allow_marshal=allow_marshal)) for key in [ "type", From cd5654aa61398918c118c8d579e3df28241c94e3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 26 Aug 2026 16:38:05 -0700 Subject: [PATCH 8/8] fix(highres): address sample storage review gaps --- .../high_res/sample_storage/__init__.py | 2 +- .../high_res/sample_storage/driver/driver.py | 103 ++++++++++++++++-- .../sample_storage/driver/environment.py | 2 +- .../high_res/sample_storage/driver/errors.py | 10 +- .../high_res/sample_storage/driver/models.py | 2 + .../high_res/sample_storage/stackers.py | 1 - .../sample_storage/tests/driver_tests.py | 32 +++++- .../sample_storage/tests/recovery_tests.py | 20 ++++ 8 files changed, 152 insertions(+), 20 deletions(-) diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py index ea6b997d6b9..3a591de7769 100644 --- a/pylabrobot/high_res/sample_storage/__init__.py +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -18,6 +18,6 @@ UnresolvedPlateTransfer, VersionInfo, ) -from .steri_store import SteriStore from .stackers import high_res_stacker +from .steri_store import SteriStore from .tundra_store import TundraStore diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py index ab8de243c90..1d63674212d 100644 --- a/pylabrobot/high_res/sample_storage/driver/driver.py +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -2,11 +2,25 @@ import logging import random from dataclasses import dataclass -from typing import Awaitable, Callable, Dict, List, Literal, Mapping, Optional, Tuple, Union, cast +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + Union, + cast, +) from pylabrobot.events import event_operation, resource_reference from pylabrobot.io.socket import Socket from pylabrobot.resources import ( + Coordinate, Plate, PlateCarrier, PlateHolder, @@ -14,6 +28,8 @@ ResourceNotFoundError, Rotation, ) +from pylabrobot.resources.barcode import Barcode +from pylabrobot.serializer import deserialize as deserialize_value from ..stackers import high_res_stacker from .environment import EnvironmentControl @@ -109,8 +125,13 @@ def __init__( port: int = 1000, read_timeout: float = 30.0, motion_timeout: float = 240.0, - ): - """ + nests: Optional[Sequence[PlateHolder]] = None, + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + """Create a HighRes sample-store resource and configure its TCP transport. + 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``. @@ -124,6 +145,10 @@ def __init__( (``home``, ``pick``, ``place``, door moves). model: Model used for model-specific behavior. Defaults to the concrete class's model and is never replaced with the device-reported product name. + nests: Existing transfer-nest resources when restoring serialized state. + barcode: Optional barcode identifying the store resource. + preferred_pickup_location: Optional gripper pickup coordinate for the store resource. + metadata: Free-form resource metadata. """ configured_model = model if model is not None else self._model_name model_info = get_model_info(configured_model) @@ -136,12 +161,19 @@ def __init__( rotation=rotation, category=category, model=configured_model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + metadata=metadata, ) # The device reports its configured nest numbers during setup. Their # robot-facing coordinates are intentionally undefined relative to the # store, so the corresponding resources are attached with location=None. self.nests: List[PlateHolder] = [] + if nests is not None: + for nest in nests: + self.assign_child_resource(nest, location=None) + self.nests.append(nest) self._racks_by_number: Dict[int, PlateCarrier] = {} self._racks_loaded = racks is not None @@ -167,6 +199,8 @@ def __init__( read_timeout=read_timeout, write_timeout=read_timeout, ) + self._host = host + self._port = port self._read_timeout = read_timeout self._motion_timeout = motion_timeout self._transport_invalidated = False @@ -195,7 +229,58 @@ def unresolved_transfer(self) -> Optional[UnresolvedPlateTransfer]: return self._unresolved_transfer def serialize(self) -> dict: - raise NotImplementedError("HighRes sample store serialization is not implemented yet.") + """Serialize the connection configuration and modeled inventory. + + An unresolved transfer cannot be serialized safely because restoring only + the last confirmed resource assignment would discard the recovery block. + """ + self._require_no_unresolved_transfer() + resource_data = Resource.serialize(self) + # These fields preserve physical stacker numbers and transfer-nest ordering. + resource_data.pop("children", None) + return { + **resource_data, + "host": self._host, + "port": self._port, + "read_timeout": self.read_timeout, + "motion_timeout": self.motion_timeout, + "racks": {str(stacker): rack.serialize() for stacker, rack in self._racks_by_number.items()}, + "nests": [nest.serialize() for nest in self.nests], + } + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False) -> "HighResSampleStorage": + """Restore a sample store and its modeled racks and transfer nests.""" + racks_data = cast(Dict[str, dict], data.get("racks", {})) + racks = { + int(stacker): PlateCarrier.deserialize(rack_data, allow_marshal=allow_marshal) + for stacker, rack_data in racks_data.items() + } + barcode_data = cast(Optional[dict], data.get("barcode")) + store = cls( + host=cast(str, data["host"]), + name=cast(str, data["name"]), + racks=racks, + size_x=cast(float, data.get("size_x", 0)), + size_y=cast(float, data.get("size_y", 0)), + size_z=cast(float, data.get("size_z", 0)), + rotation=cast(Optional[Rotation], deserialize_value(data.get("rotation"))), + category=cast(Optional[str], data.get("category")), + model=cast(Optional[str], data.get("model")), + port=cast(int, data.get("port", 1000)), + read_timeout=cast(float, data.get("read_timeout", 30.0)), + motion_timeout=cast(float, data.get("motion_timeout", 240.0)), + nests=[ + PlateHolder.deserialize(nest_data, allow_marshal=allow_marshal) + for nest_data in cast(List[dict], data.get("nests", [])) + ], + barcode=Barcode(**barcode_data) if barcode_data is not None else None, + preferred_pickup_location=cast( + Optional[Coordinate], deserialize_value(data.get("preferred_pickup_location")) + ), + metadata=cast(Optional[Mapping[str, Any]], data.get("metadata")), + ) + return store # --- lifecycle ------------------------------------------------------------ @@ -431,7 +516,7 @@ async def request_environment(self) -> Dict[str, EnvironmentParameter]: except (ValueError, IndexError): continue - def _opt(i: int, parts=parts) -> Optional[float]: + def _opt(i: int) -> Optional[float]: try: return float(parts[i]) except (ValueError, IndexError): @@ -580,7 +665,7 @@ async def request_stacker_barcodes( # --- motion --------------------------------------------------------------- - async def home(self): + async def home(self) -> None: """Home the system. The first step closes all doors, which requires the pneumatic supply (clean dry air >80 psi); without it this raises :class:`HighResSampleStorageError` ("Unable to close all doors").""" @@ -651,7 +736,7 @@ async def recover(self) -> bool: logger.error("Motion recovery failed for %s after 3 attempts", self.name) return False - async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = True): + async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = True) -> None: """Retrieve a plate from ``(stacker, slot)`` to ``nest``. ``close_door=False`` re-opens the doors after the transfer (see :meth:`_place`). @@ -690,7 +775,7 @@ async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = Tru if not close_door: await self.open_all_doors() - async def _place(self, stacker: int, slot: int, nest: int, close_door: bool = True): + async def _place(self, stacker: int, slot: int, nest: int, close_door: bool = True) -> None: """Place the plate at ``nest`` into ``(stacker, slot)``. The store re-seals its doors as part of every transfer, so ``close_door`` @@ -710,7 +795,7 @@ async def _place(self, stacker: int, slot: int, nest: int, close_door: bool = Tr try: await self._send_command(command, timeout=self.motion_timeout) except HighResSampleStorageError as exc: - if left_unsafe(exc.error_lines) or not await self.request_is_homed(): + if left_unsafe(exc.error_lines) or not await self.request_is_parked(): logger.error("Place left %s unsafe: %s", self.name, exc) raise HighResSampleStorageFault(command, exc.error_lines) from exc logger.error("Place failed on %s: %s", self.name, exc) diff --git a/pylabrobot/high_res/sample_storage/driver/environment.py b/pylabrobot/high_res/sample_storage/driver/environment.py index e2d3923b51c..1900359a569 100644 --- a/pylabrobot/high_res/sample_storage/driver/environment.py +++ b/pylabrobot/high_res/sample_storage/driver/environment.py @@ -45,7 +45,7 @@ class EnvironmentControl: device protocol uses percentages, so ``0.05`` CO2 is sent as ``5``. """ - def __init__(self, driver: "HighResSampleStorage"): + def __init__(self, driver: "HighResSampleStorage") -> None: super().__init__() self._driver = driver self._parameters: Dict[str, EnvironmentParameter] = {} diff --git a/pylabrobot/high_res/sample_storage/driver/errors.py b/pylabrobot/high_res/sample_storage/driver/errors.py index 0c9bf086199..35c96948987 100644 --- a/pylabrobot/high_res/sample_storage/driver/errors.py +++ b/pylabrobot/high_res/sample_storage/driver/errors.py @@ -2,7 +2,7 @@ class NoFreeSiteError(Exception): - pass + """Raised when no free stacker site can accommodate a plate.""" class HighResSampleStorageError(Exception): @@ -14,7 +14,7 @@ class HighResSampleStorageError(Exception): :attr:`error_lines`. """ - def __init__(self, command: str, error_lines: List[str]): + def __init__(self, command: str, error_lines: List[str]) -> None: self.command = command self.error_lines = error_lines detail = error_lines[-1] if error_lines else "no error detail returned" @@ -24,7 +24,7 @@ def __init__(self, command: str, error_lines: List[str]): class HighResSampleStorageAbortedError(Exception): """A command returned an ``ABORTED!`` completion status.""" - def __init__(self, command: str): + def __init__(self, command: str) -> None: self.command = command super().__init__(f"'{command}' was aborted") @@ -32,7 +32,7 @@ def __init__(self, command: str): class HighResSampleStorageProtocolError(Exception): """The device returned a malformed or mismatched command response.""" - def __init__(self, command: str, response: str, detail: str): + def __init__(self, command: str, response: str, detail: str) -> None: self.command = command self.response = response self.detail = detail @@ -59,7 +59,7 @@ class HighResSampleStorageFault(HighResSampleStorageError): issuing further motion. """ - def __init__(self, command: str, error_lines: List[str]): + def __init__(self, command: str, error_lines: List[str]) -> None: super().__init__(command, error_lines) self.args = (f"{self.args[0]}; machine is unsafe — call recover()",) diff --git a/pylabrobot/high_res/sample_storage/driver/models.py b/pylabrobot/high_res/sample_storage/driver/models.py index 15b333c8dad..8b817f626fb 100644 --- a/pylabrobot/high_res/sample_storage/driver/models.py +++ b/pylabrobot/high_res/sample_storage/driver/models.py @@ -4,6 +4,8 @@ @dataclass(frozen=True) class ModelInfo: + """Environmental capabilities and limits for one sample-store model.""" + has_environment_control: bool temperature_range: Optional[Tuple[float, float]] humidity_range: Optional[Tuple[float, float]] diff --git a/pylabrobot/high_res/sample_storage/stackers.py b/pylabrobot/high_res/sample_storage/stackers.py index bf18c9b4060..4977c8ba775 100644 --- a/pylabrobot/high_res/sample_storage/stackers.py +++ b/pylabrobot/high_res/sample_storage/stackers.py @@ -1,6 +1,5 @@ from pylabrobot.resources import Coordinate, PlateCarrier, PlateHolder - _STACKER_SIZE_X = 112.3 _STACKER_SIZE_Y = 146.6 _SITE_SIZE_X = 85.48 diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py index 528303a22b8..ab0f5a5e8ce 100644 --- a/pylabrobot/high_res/sample_storage/tests/driver_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -1,5 +1,6 @@ import asyncio import inspect +import json import unittest from typing import Dict, List from unittest.mock import AsyncMock @@ -682,9 +683,20 @@ async def test_tray_maps_to_nest(self): with self.assertRaises(ValueError): self.retrieval._nest_for_tray(2) - def test_serialization_is_not_implemented(self): - with self.assertRaisesRegex(NotImplementedError, "serialization is not implemented"): - self.driver.serialize() + async def test_serialization_round_trip(self): + await self.driver.setup() + self.driver.metadata["owner"] = "lab" + + serialized = json.loads(json.dumps(self.driver.serialize())) + restored = SteriStore.deserialize(serialized) + + self.assertIsInstance(restored, SteriStore) + self.assertEqual(restored.metadata, {"owner": "lab"}) + self.assertEqual( + [nest.name for nest in restored.nests], [nest.name for nest in self.driver.nests] + ) + self.assertEqual(restored.racks, []) + self.assertEqual(restored.serialize(), serialized) class HighResSampleStorageBookkeepingTests(unittest.IsolatedAsyncioTestCase): @@ -718,6 +730,18 @@ def _set_nest_status(self, nest_1: str, nest_2: str = "CLEAR") -> None: "OK! neststatus 12", ] + def test_serialization_preserves_stacker_mapping_and_inventory(self): + serialized = json.loads(json.dumps(self.driver.serialize())) + + restored = HighResSampleStorage.deserialize(serialized) + + self.assertEqual(list(restored._racks_by_number), [1]) + restored_plate = restored.get_site_by_plate_name("plate").resource + self.assertIsNotNone(restored_plate) + assert restored_plate is not None + self.assertEqual(restored_plate.name, "plate") + self.assertEqual(restored.serialize(), serialized) + async def test_fetch_moves_plate_resource_to_nest(self): self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] @@ -973,6 +997,8 @@ async def test_timeout_after_pick_ack_records_unresolved_transfer(self): self.assertEqual(transfer.error_type, "TimeoutError") self.assertIs(self.plate.parent, self.site) self.assertEqual(socket.stop_calls, 1) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + self.driver.serialize() result = await self.driver.resolve_unresolved_transfer("source") diff --git a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py index 97b91de5bc0..47b0f76bd8f 100644 --- a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -227,6 +227,26 @@ async def test_unsafe_place_raises_fault(self): with self.assertRaises(HighResSampleStorageFault): await self.retrieval._place(2, 5, 1) + async def test_place_raises_fault_when_homed_but_extended(self): + generic_error = [ + "ACK! place 2 5 1 1", + "Error 1: 1: motor fault", + "ERROR! place 2 5 1 1", + ] + sock = ScriptedSocket( + [ + ("place 2 5 1", generic_error), + ("homedstatus", self._homed(2)), + ("status", self._status(3, y=256.0)), + ] + ) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._place(2, 5, 1) + + self.assertEqual(sock.commands, ["place 2 5 1", "homedstatus", "status"]) + async def test_place_default_leaves_doors_sealed(self): sock = ScriptedSocket([("place 2 5 1", _ok("place 2 5 1", 1))]) self.driver.io = sock # type: ignore[assignment]