Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
dbbc4d7
sample_storage: serialize the io object instead of socket privates
rickwierenga Jul 2, 2026
09d914d
Merge origin/v1b1 into highres-tundrastore-driver
rickwierenga Jul 2, 2026
40c8fd7
sample_storage: make default_tray_index 0-based (drop 1-based surface)
rickwierenga Jul 3, 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
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
66 changes: 53 additions & 13 deletions pylabrobot/capabilities/automated_retrieval/automated_retrieval.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from typing import Optional
from typing import List, Optional

from pylabrobot.capabilities.capability import Capability, CapabilityBackend
from pylabrobot.resources import Plate, PlateHolder, ResourceNotFoundError


class AutomatedRetrieval(Capability):
"""Shared base for storage-retrieval capabilities that move plates to and from a single
transfer position -- the "loading tray".
"""Shared base for storage-retrieval capabilities that move plates to and from one or more
transfer positions -- the "loading tray(s)".

Concrete capabilities differ only in how storage locations are addressed:

Expand All @@ -15,22 +15,62 @@ class AutomatedRetrieval(Capability):
* :class:`~pylabrobot.capabilities.automated_retrieval.StackerRetrieval` is *sequential* --
single-ended LIFO stacks.

This base owns the loading tray and the small amount of plate-movement plumbing the two share
(loading-tray access and the summary table), so the concrete capabilities only implement their
location-addressing logic.
This base owns the loading tray(s) and the small amount of plate-movement plumbing the two share
(loading-tray resolution and the summary table), so the concrete capabilities only implement
their location-addressing logic.

Most devices have a single loading tray (pass ``loading_tray``). Devices with several transfer
nests pass ``loading_trays`` -- one :class:`PlateHolder` per nest -- and address them by
``tray_index`` (0-based). ``tray_index=None`` resolves to the backend's ``default_tray_index``
(falling back to the first tray), the same index the backend resolves ``None`` to, so the
resource tree and the hardware always agree on which tray a ``None`` request targets.
"""

def __init__(self, backend: CapabilityBackend, loading_tray: Optional[PlateHolder] = None):
def __init__(
self,
backend: CapabilityBackend,
loading_tray: Optional[PlateHolder] = None,
loading_trays: Optional[List[PlateHolder]] = None,
):
super().__init__(backend=backend)
self.loading_tray = loading_tray
if loading_tray is not None and loading_trays is not None:
raise ValueError("Pass either loading_tray or loading_trays, not both.")
if loading_trays is None:
loading_trays = [loading_tray] if loading_tray is not None else []
self.loading_trays: List[PlateHolder] = loading_trays

@property
def loading_tray(self) -> Optional[PlateHolder]:
"""The default loading tray (single-tray convenience accessor)."""
if not self.loading_trays:
return None
return self.loading_trays[self._default_tray_index()]

def _default_tray_index(self) -> int:
# Tray-having backends expose ``default_tray_index``; fall back to the first tray.
return getattr(self.backend, "default_tray_index", 0)

def _require_loading_tray(self) -> PlateHolder:
if self.loading_tray is None:
def _loading_tray(self, tray_index: Optional[int] = None) -> PlateHolder:
"""Resolve a (possibly ``None``) ``tray_index`` to a configured loading tray.

``None`` maps to the backend's ``default_tray_index`` -- the same index the backend resolves
``None`` to -- so bookkeeping and motion stay in sync.
"""
if not self.loading_trays:
raise RuntimeError("No loading tray configured for this capability.")
return self.loading_tray
idx = self._default_tray_index() if tray_index is None else tray_index
if not 0 <= idx < len(self.loading_trays):
raise ValueError(
f"tray_index {idx} out of range; this device has "
f"{len(self.loading_trays)} loading tray(s)."
)
return self.loading_trays[idx]

def _require_loading_tray(self, tray_index: Optional[int] = None) -> PlateHolder:
return self._loading_tray(tray_index)

def _plate_on_loading_tray(self) -> Plate:
tray = self._require_loading_tray()
def _plate_on_loading_tray(self, tray_index: Optional[int] = None) -> Plate:
tray = self._loading_tray(tray_index)
plate = tray.resource
if not isinstance(plate, Plate):
raise ResourceNotFoundError("No plate on the loading tray.")
Expand Down
54 changes: 46 additions & 8 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,13 +9,38 @@
class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta):
"""Abstract backend for random-access automated plate retrieval/storage devices."""

@property
def default_tray_index(self) -> int:
"""0-based index of the loading tray used when ``tray_index`` is ``None``.

Single-tray devices use tray 0. Multi-tray devices override this to point at
whichever tray they treat as the default; the capability resolves ``None`` to
this same index for its resource bookkeeping, so both sides always agree on
which physical tray a ``None`` request targets.
"""
return 0

@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.
"""


class StackerBackend(CapabilityBackend, metaclass=ABCMeta):
Expand All @@ -25,10 +51,22 @@ class StackerBackend(CapabilityBackend, metaclass=ABCMeta):
transfers between a stack and the loading tray.
"""

@property
def default_tray_index(self) -> int:
"""0-based index of the loading tray used when ``tray_index`` is ``None`` (see
:attr:`AutomatedRetrievalBackend.default_tray_index`)."""
return 0

@abstractmethod
async def downstack(self, stack: ResourceStack):
"""Move the accessible (top) plate of ``stack`` onto the loading tray."""
async def downstack(self, stack: ResourceStack, tray_index: Optional[int] = None):
"""Move the accessible (top) plate of ``stack`` onto a loading tray.

``tray_index=None`` selects the device's default tray.
"""

@abstractmethod
async def upstack(self, stack: ResourceStack, plate: Plate):
"""Move a plate from the loading tray onto ``stack``."""
async def upstack(self, stack: ResourceStack, plate: Plate, tray_index: Optional[int] = None):
"""Move a plate from a loading tray onto ``stack``.

``tray_index=None`` selects the device's default tray.
"""
17 changes: 9 additions & 8 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 @@ -12,18 +13,18 @@
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)


class StackerChatterboxBackend(StackerBackend):
"""Chatterbox backend for device-free testing."""

async def downstack(self, stack: ResourceStack):
logger.info("Downstacking accessible plate from stack %s.", stack.name)
async def downstack(self, stack: ResourceStack, tray_index: Optional[int] = None):
logger.info("Downstacking accessible plate from stack %s to tray %s.", stack.name, tray_index)

async def upstack(self, stack: ResourceStack, plate: Plate):
logger.info("Upstacking plate %s onto stack %s.", plate.name, stack.name)
async def upstack(self, stack: ResourceStack, plate: Plate, tray_index: Optional[int] = None):
logger.info("Upstacking plate %s from tray %s onto stack %s.", plate.name, tray_index, stack.name)
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ class NoFreeSiteError(Exception):
class RandomAccessRetrieval(AutomatedRetrieval):
"""Automated plate retrieval/storage capability (random access).

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
random-access automated storage systems so devices composing this capability
do not have to reimplement it. The loading tray and the shared plate-movement
plumbing live on :class:`~pylabrobot.capabilities.automated_retrieval.AutomatedRetrieval`,
which the sequential :class:`~pylabrobot.capabilities.automated_retrieval.StackerRetrieval`
also uses.
also uses (including the single/multi loading-tray handling and ``tray_index`` resolution).

See :doc:`/user_guide/capabilities/automated-retrieval` for a walkthrough.
"""
Expand All @@ -36,8 +36,9 @@ def __init__(
backend: AutomatedRetrievalBackend,
racks: Optional[List[PlateCarrier]] = None,
loading_tray: Optional[PlateHolder] = None,
loading_trays: Optional[List[PlateHolder]] = None,
):
super().__init__(backend=backend, loading_tray=loading_tray)
super().__init__(backend=backend, loading_tray=loading_tray, loading_trays=loading_trays)
self.backend: AutomatedRetrievalBackend = backend
self._racks: List[PlateCarrier] = racks if racks is not None else []

Expand Down Expand Up @@ -86,26 +87,34 @@ 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."""
loading_tray = self._require_loading_tray()
async def fetch_plate_to_loading_tray(
self, plate_name: str, tray_index: Optional[int] = None
) -> Plate:
"""Retrieve the named plate from storage onto loading tray ``tray_index``.

``tray_index=None`` uses the backend's default tray.
"""
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()
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: Optional[int] = None,
):
"""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.
let the capability pick a fitting free site. ``tray_index=None`` uses the
backend's default tray.
"""
plate = self._plate_on_loading_tray()
plate = self._plate_on_loading_tray(tray_index)

if site == "random":
site = self.find_random_site(plate)
Expand All @@ -117,7 +126,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
Loading