Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
818c0db
feat(highres): add TundraStore plate store driver
Jun 13, 2026
494ceb6
feat(automated_retrieval): multi-tray support; use it for TundraStore…
Jun 15, 2026
c4e6a51
feat(highres/tundrastore): settings client + robust pick fault handling
Jun 16, 2026
cfc022c
feat(highres/tundrastore): presence query methods
Jun 16, 2026
b2aaddd
fix(highres/tundrastore): recover() no-op'd on the homedstatus lie
Jun 16, 2026
3f820b6
feat(highres/tundrastore): close_door option on pick/place
Jun 17, 2026
60c395d
Rename automated-retrieval `tray` parameter to `tray_index`
rickwierenga Jun 28, 2026
ae356cc
Remove tundrastore constants.py, relocate constants to their users
rickwierenga Jun 28, 2026
491edf8
Inline single-tray guard, drop ensure_single_tray helper
rickwierenga Jun 28, 2026
bdb2b4f
Drop DoorStatus/NestStatus wrappers, return dicts directly
rickwierenga Jun 28, 2026
aa9ca2b
Make TundraStoreSettings a frozen, fully-typed dataclass
rickwierenga Jun 28, 2026
3050b24
Collapse settings loaders to a single from_lines
rickwierenga Jun 28, 2026
b152278
Merge remote-tracking branch 'origin/v1b1' into highres-tundrastore-d…
rickwierenga Jun 28, 2026
4263dff
Follow #1125 standard: extend AutomatedRetrieval to multiple loading …
rickwierenga Jun 28, 2026
be36aca
Fix Liconic/Cytomat hello-world notebooks for #1125 API
rickwierenga Jun 28, 2026
7949eb4
TundraStore settings: inline value conversion, drop _coerce helper
rickwierenga Jun 28, 2026
63138ba
TundraStore: replace DoorState/NestState enums with string literals
rickwierenga Jun 28, 2026
8d6124a
TundraStore: split monolithic backend into driver + per-capability ba…
rickwierenga Jun 28, 2026
b5a5ef3
Rename highres.tundrastore -> highres.sample_storage; add device subc…
rickwierenga Jun 28, 2026
45c86c3
sample_storage: split backend.py into a driver/ package, one class pe…
rickwierenga Jun 28, 2026
88dcfc9
sample_storage: move tests into a tests/ package, split by concern
rickwierenga Jun 28, 2026
bf0663e
sample_storage: prefix device-read methods with request_
rickwierenga Jun 28, 2026
f31a19f
sample_storage: chatterbox builds on the real driver, like STAR's cha…
rickwierenga Jun 28, 2026
cd30374
sample_storage: drop the chatterbox driver for now
rickwierenga Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/user_guide/capabilities/automated-retrieval.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{
"cell_type": "code",
"metadata": {},
"source": "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\nfrom pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\nfrom pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb, PlateCarrier, PlateHolder\n\n# The capability owns the storage racks and the loading tray, and implements the\n# site bookkeeping (free-site counting, lookup, selection). A device that composes\n# AutomatedRetrieval passes these in; here we build them by hand.\nsite = PlateHolder(name=\"site_0\", size_x=127.76, size_y=85.48, size_z=30, pedestal_size_z=0)\nsite.location = Coordinate(0, 0, 0)\nrack = PlateCarrier(name=\"rack_0\", size_x=135, size_y=95, size_z=400, sites={0: site})\nloading_tray = PlateHolder(\n name=\"loading_tray\", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0\n)\n\nretrieval = AutomatedRetrieval(\n backend=AutomatedRetrievalChatterboxBackend(),\n racks=[rack],\n loading_tray=loading_tray,\n)\nawait retrieval._on_setup()\n\n# place a plate into storage so we have something to retrieve\nsite.assign_child_resource(Cor_96_wellplate_360ul_Fb(name=\"my_plate\"))\nprint(\"free sites:\", retrieval.get_num_free_sites())",
"source": "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\nfrom pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\nfrom pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb, PlateCarrier, PlateHolder\n\n# The capability owns the storage racks and the loading tray(s), and implements the\n# site bookkeeping (free-site counting, lookup, selection). A device that composes\n# AutomatedRetrieval passes these in; here we build them by hand. Pass one loading\n# tray per transfer nest; most devices have just one.\nsite = PlateHolder(name=\"site_0\", size_x=127.76, size_y=85.48, size_z=30, pedestal_size_z=0)\nsite.location = Coordinate(0, 0, 0)\nrack = PlateCarrier(name=\"rack_0\", size_x=135, size_y=95, size_z=400, sites={0: site})\nloading_tray = PlateHolder(\n name=\"loading_tray\", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0\n)\n\nretrieval = AutomatedRetrieval(\n backend=AutomatedRetrievalChatterboxBackend(),\n racks=[rack],\n loading_trays=[loading_tray],\n)\nawait retrieval._on_setup()\n\n# place a plate into storage so we have something to retrieve\nsite.assign_child_resource(Cor_96_wellplate_360ul_Fb(name=\"my_plate\"))\nprint(\"free sites:\", retrieval.get_num_free_sites())",
"execution_count": null,
"outputs": []
},
Expand Down
8 changes: 4 additions & 4 deletions docs/user_guide/liconic/stx/hello-world.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
{
"cell_type": "code",
"id": "562vs3qn86",
"source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.take_in_plate(\"smallest\") # store in the smallest free site that fits",
"source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.retrieval.take_in_plate(\"smallest\") # store in the smallest free site that fits",
"metadata": {},
"execution_count": null,
"outputs": []
Expand All @@ -49,7 +49,7 @@
{
"cell_type": "code",
"id": "2w9vsj6i8bp",
"source": "await incubator.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource",
"source": "await incubator.retrieval.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource",
"metadata": {},
"execution_count": null,
"outputs": []
Expand All @@ -63,7 +63,7 @@
{
"cell_type": "code",
"id": "rl1sabpoa6i",
"source": "await incubator.take_in_plate(\"random\") # random free site\n# await incubator.take_in_plate(racks[3].sites[0]) # specific rack and position",
"source": "await incubator.retrieval.take_in_plate(\"random\") # random free site\n# await incubator.retrieval.take_in_plate(racks[3].sites[0]) # specific rack and position",
"metadata": {},
"execution_count": null,
"outputs": []
Expand All @@ -77,7 +77,7 @@
{
"cell_type": "code",
"id": "rvqkhqaii3r",
"source": "print(incubator.summary())",
"source": "print(incubator.retrieval.summary())",
"metadata": {},
"execution_count": null,
"outputs": []
Expand Down
10 changes: 5 additions & 5 deletions docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,27 @@
{
"cell_type": "markdown",
"id": "snc2ihvinf",
"source": "## Storing a plate\n\nPlace a plate on the loading tray and call {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.take_in_plate` to move it into storage. You can specify `\"smallest\"` (smallest free site that fits), `\"random\"`, or an explicit site. See [Automated Retrieval](../../capabilities/automated-retrieval) for the full capability API.",
"source": "## Storing a plate\n\nPlace a plate on the loading tray and call {meth}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval.take_in_plate` (via `cytomat.retrieval`) to move it into storage. You can specify `\"smallest\"` (smallest free site that fits), `\"random\"`, or an explicit site. See [Automated Retrieval](../../capabilities/automated-retrieval) for the full capability API.",
"metadata": {}
},
{
"cell_type": "code",
"id": "7mnnfp1pup6",
"source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\ncytomat.loading_tray.assign_child_resource(plate)\n\nawait cytomat.take_in_plate(\"smallest\") # choose the smallest free site\n\n# other options:\n# await cytomat.take_in_plate(\"random\") # random free site\n# await cytomat.take_in_plate(rack[3]) # store at rack position 3",
"source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\ncytomat.loading_tray.assign_child_resource(plate)\n\nawait cytomat.retrieval.take_in_plate(\"smallest\") # choose the smallest free site\n\n# other options:\n# await cytomat.retrieval.take_in_plate(\"random\") # random free site\n# await cytomat.retrieval.take_in_plate(rack[3]) # store at rack position 3",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "jtiua5orv2m",
"source": "## Retrieving a plate\n\nUse {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.fetch_plate_to_loading_tray` to move a plate from storage back to the loading tray.",
"source": "## Retrieving a plate\n\nUse {meth}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval.fetch_plate_to_loading_tray` (via `cytomat.retrieval`) to move a plate from storage back to the loading tray.",
"metadata": {}
},
{
"cell_type": "code",
"id": "7o3qh6ahz29",
"source": "await cytomat.fetch_plate_to_loading_tray(\"my_plate\")\nretrieved = cytomat.loading_tray.resource",
"source": "await cytomat.retrieval.fetch_plate_to_loading_tray(\"my_plate\")\nretrieved = cytomat.loading_tray.resource",
"metadata": {},
"execution_count": null,
"outputs": []
Expand Down Expand Up @@ -105,7 +105,7 @@
{
"cell_type": "code",
"id": "6ur6evj2pt",
"source": "print(cytomat.summary())",
"source": "print(cytomat.retrieval.summary())",
"metadata": {},
"execution_count": null,
"outputs": []
Expand Down
45 changes: 30 additions & 15 deletions pylabrobot/capabilities/automated_retrieval/automated_retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,44 @@ class NoFreeSiteError(Exception):
class AutomatedRetrieval(Capability):
"""Automated plate retrieval/storage capability.

Owns the storage racks and the loading tray, and implements the site
Owns the storage racks and the loading tray(s), and implements the site
bookkeeping (free-site counting, lookup and selection) shared by all
automated storage systems so devices composing this capability do not have to
reimplement it.

Most devices have a single loading tray and pass it as a one-element list.
Devices with several transfer nests (e.g. the TundraStore) pass one
:class:`PlateHolder` per nest and address them by ``tray_index`` (0-based,
defaulting to the first tray).

See :doc:`/user_guide/capabilities/automated-retrieval` for a walkthrough.
"""

def __init__(
self,
backend: AutomatedRetrievalBackend,
racks: Optional[List[PlateCarrier]] = None,
loading_tray: Optional[PlateHolder] = None,
loading_trays: Optional[List[PlateHolder]] = None,
):
super().__init__(backend=backend)
self.backend: AutomatedRetrievalBackend = backend
self._racks: List[PlateCarrier] = racks if racks is not None else []
self.loading_tray = loading_tray
self.loading_trays: List[PlateHolder] = loading_trays if loading_trays is not None else []

@property
def racks(self) -> List[PlateCarrier]:
return self._racks

def _loading_tray(self, tray_index: int) -> PlateHolder:
if not self.loading_trays:
raise RuntimeError("No loading tray configured for this automated retrieval.")
if not 0 <= tray_index < len(self.loading_trays):
raise ValueError(
f"tray_index {tray_index} out of range; this device has "
f"{len(self.loading_trays)} loading tray(s)."
)
return self.loading_trays[tray_index]

# -- site bookkeeping --

def get_num_free_sites(self) -> int:
Expand Down Expand Up @@ -83,29 +98,29 @@ def find_random_site(self, plate: Plate) -> PlateHolder:
# -- storage operations --

@need_capability_ready
async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate:
"""Retrieve the plate with the given name from storage onto the loading tray."""
if self.loading_tray is None:
raise RuntimeError("No loading tray configured for this automated retrieval.")
async def fetch_plate_to_loading_tray(self, plate_name: str, tray_index: int = 0) -> Plate:
"""Retrieve the named plate from storage onto loading tray ``tray_index``."""
tray = self._loading_tray(tray_index)
site = self.get_site_by_plate_name(plate_name)
plate = cast(Plate, site.resource)
await self.backend.fetch_plate_to_loading_tray(plate)
await self.backend.fetch_plate_to_loading_tray(plate, tray_index=tray_index)
plate.unassign()
self.loading_tray.assign_child_resource(plate)
tray.assign_child_resource(plate)
return plate

@need_capability_ready
async def take_in_plate(
self, site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest"
self,
site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest",
tray_index: int = 0,
):
"""Take the plate from the loading tray and store it into storage.
"""Take the plate from loading tray ``tray_index`` and store it into storage.

`site` may be an explicit free `PlateHolder`, or `"smallest"`/`"random"` to
let the capability pick a fitting free site.
"""
if self.loading_tray is None:
raise RuntimeError("No loading tray configured for this automated retrieval.")
plate = cast(Optional[Plate], self.loading_tray.resource)
tray = self._loading_tray(tray_index)
plate = cast(Optional[Plate], tray.resource)
if plate is None:
raise ResourceNotFoundError("No plate on the loading tray.")

Expand All @@ -119,7 +134,7 @@ async def take_in_plate(
else:
raise ValueError(f"Invalid site: {site}")

await self.backend.store_plate(plate, site)
await self.backend.store_plate(plate, site, tray_index=tray_index)
plate.unassign()
site.assign_child_resource(plate)

Expand Down
23 changes: 19 additions & 4 deletions pylabrobot/capabilities/automated_retrieval/backend.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from abc import ABCMeta, abstractmethod
from typing import Optional

from pylabrobot.capabilities.capability import CapabilityBackend
from pylabrobot.resources import Plate, PlateHolder
Expand All @@ -8,9 +9,23 @@ class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta):
"""Abstract backend for automated plate retrieval/storage devices."""

@abstractmethod
async def fetch_plate_to_loading_tray(self, plate: Plate):
"""Retrieve a plate from storage and place it on the loading tray."""
async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None):
"""Retrieve a plate from storage and place it on a loading tray.

Args:
plate: The plate to retrieve.
tray_index: 0-based index of the loading tray to deliver the plate to. ``None``
selects the device's default tray. Devices with a single loading tray
accept ``None``/``0`` and reject any other value.
"""

@abstractmethod
async def store_plate(self, plate: Plate, site: PlateHolder):
"""Store a plate from the loading tray into the given site."""
async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None):
"""Store a plate from a loading tray into the given site.

Args:
plate: The plate to store.
site: The destination storage site.
tray_index: 0-based index of the loading tray the plate is currently on. ``None``
selects the device's default tray.
"""
9 changes: 5 additions & 4 deletions pylabrobot/capabilities/automated_retrieval/chatterbox.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Optional

from pylabrobot.resources.carrier import PlateHolder
from pylabrobot.resources.plate import Plate
Expand All @@ -11,8 +12,8 @@
class AutomatedRetrievalChatterboxBackend(AutomatedRetrievalBackend):
"""Chatterbox backend for device-free testing."""

async def fetch_plate_to_loading_tray(self, plate: Plate):
logger.info("Fetching plate %s to loading tray.", plate.name)
async def fetch_plate_to_loading_tray(self, plate: Plate, tray_index: Optional[int] = None):
logger.info("Fetching plate %s to loading tray %s.", plate.name, tray_index)

async def store_plate(self, plate: Plate, site: PlateHolder):
logger.info("Storing plate %s at site %s.", plate.name, site.name)
async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: Optional[int] = None):
logger.info("Storing plate %s at site %s (tray %s).", plate.name, site.name, tray_index)
Empty file added pylabrobot/highres/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions pylabrobot/highres/sample_storage/__init__.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions pylabrobot/highres/sample_storage/driver/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .automated_retrieval import HighResSampleStorageAutomatedRetrievalBackend
from .driver import HighResSampleStorageDriver
from .humidity import HighResSampleStorageHumidityControllerBackend
from .temperature import HighResSampleStorageTemperatureControllerBackend
Loading
Loading