Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Flex Stacker Module Support for EVT #17300

Merged
merged 16 commits into from
Jan 17, 2025
Merged
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@
"errors": [
{
"createdAt": "TIMESTAMP",
"detail": "ValueError [line 15]: Cannot load a module onto a staging slot.",
"detail": "ValueError [line 15]: Cannot load temperature module gen2 onto a staging slot.",
"errorCode": "4000",
"errorInfo": {},
"errorType": "ExceptionInProtocolError",
@@ -34,12 +34,12 @@
"wrappedErrors": [
{
"createdAt": "TIMESTAMP",
"detail": "ValueError: Cannot load a module onto a staging slot.",
"detail": "ValueError: Cannot load temperature module gen2 onto a staging slot.",
"errorCode": "4000",
"errorInfo": {
"args": "('Cannot load a module onto a staging slot.',)",
"args": "('Cannot load temperature module gen2 onto a staging slot.',)",
"class": "ValueError",
"traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line N, in exec_run\n exec(\"run(__context)\", new_globs)\n\n File \"<string>\", line N, in <module>\n\n File \"Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4.py\", line N, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line N, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line N, in load_module\n raise ValueError(\"Cannot load a module onto a staging slot.\")\n"
"traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line N, in exec_run\n exec(\"run(__context)\", new_globs)\n\n File \"<string>\", line N, in <module>\n\n File \"Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4.py\", line N, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line N, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line N, in load_module\n raise ValueError(f\"Cannot load {module_name} onto a staging slot.\")\n"
},
"errorType": "PythonException",
"id": "UUID",
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_api/__init__.py
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@
HeaterShakerContext,
MagneticBlockContext,
AbsorbanceReaderContext,
FlexStackerContext,
)
from .disposal_locations import TrashBin, WasteChute
from ._liquid import Liquid, LiquidClass
@@ -70,6 +71,7 @@
"HeaterShakerContext",
"MagneticBlockContext",
"AbsorbanceReaderContext",
"FlexStackerContext",
"ParameterContext",
"Labware",
"TrashBin",
3 changes: 3 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/deck_conflict.py
Original file line number Diff line number Diff line change
@@ -300,6 +300,9 @@ def _map_module(
is_semi_configuration=False,
),
)
elif module_type == ModuleType.FLEX_STACKER:
# TODO: This is a placeholder. We need to implement this.
return None
else:
return (
mapped_location,
18 changes: 16 additions & 2 deletions api/src/opentrons/protocol_api/core/engine/module_core.py
Original file line number Diff line number Diff line change
@@ -700,16 +700,30 @@ class FlexStackerCore(ModuleCore, AbstractFlexStackerCore):

_sync_module_hardware: SynchronousAdapter[hw_modules.FlexStacker]

def set_static_mode(self, static: bool) -> None:
"""Set the Flex Stacker's static mode.

The Flex Stacker cannot retrieve and or store when in static mode.
This allows the Flex Stacker carriage to be used as a staging slot,
and allowed the labware to be loaded onto it.
"""
self._engine_client.execute_command(
cmd.flex_stacker.ConfigureParams(
moduleId=self.module_id,
static=static,
)
)

def retrieve(self) -> None:
"""Retrieve a labware from the bottom of the Flex Stacker's stack."""
"""Retrieve a labware from the Flex Stacker's hopper."""
self._engine_client.execute_command(
cmd.flex_stacker.RetrieveParams(
moduleId=self.module_id,
)
)

def store(self) -> None:
"""Store a labware at the bottom of the Flex Stacker's stack."""
"""Store a labware into Flex Stacker's hopper."""
self._engine_client.execute_command(
cmd.flex_stacker.StoreParams(
moduleId=self.module_id,
30 changes: 30 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/protocol.py
Original file line number Diff line number Diff line change
@@ -75,6 +75,7 @@
NonConnectedModuleCore,
MagneticBlockCore,
AbsorbanceReaderCore,
FlexStackerCore,
)
from .exceptions import InvalidModuleLocationError
from . import load_labware_params, deck_conflict, overlap_versions
@@ -373,6 +374,34 @@ def load_lid(
self._labware_cores_by_id[labware_core.labware_id] = labware_core
return labware_core

def load_labware_to_flex_stacker_hopper(
self,
module_core: Union[ModuleCore, NonConnectedModuleCore],
load_name: str,
quantity: int,
label: Optional[str],
namespace: Optional[str],
version: Optional[int],
lid: Optional[str],
) -> None:
"""Load one or more labware with or without a lid to the flex stacker hopper."""
assert isinstance(module_core, FlexStackerCore)
for _ in range(quantity):
labware_core = self.load_labware(
load_name=load_name,
location=module_core,
label=label,
namespace=namespace,
version=version,
)
if lid is not None:
self.load_lid(
load_name=lid,
location=labware_core,
namespace=namespace,
version=version,
)

def move_labware(
self,
labware_core: LabwareCore,
@@ -726,6 +755,7 @@ def _create_module_core(
ModuleType.THERMOCYCLER: ThermocyclerModuleCore,
ModuleType.HEATER_SHAKER: HeaterShakerModuleCore,
ModuleType.ABSORBANCE_READER: AbsorbanceReaderCore,
ModuleType.FLEX_STACKER: FlexStackerCore,
}

module_type = load_module_result.model.as_type()
Original file line number Diff line number Diff line change
@@ -524,6 +524,19 @@ def load_lid_stack(
"""Load a Stack of Lids to a given location, creating a Lid Stack."""
raise APIVersionError(api_element="Lid stack")

def load_labware_to_flex_stacker_hopper(
self,
module_core: legacy_module_core.LegacyModuleCore,
load_name: str,
quantity: int,
label: Optional[str],
namespace: Optional[str],
version: Optional[int],
lid: Optional[str],
) -> None:
"""Load labware to a Flex stacker hopper."""
raise APIVersionError(api_element="Flex stacker")

def get_module_cores(self) -> List[legacy_module_core.LegacyModuleCore]:
"""Get loaded module cores."""
return self._module_cores
9 changes: 6 additions & 3 deletions api/src/opentrons/protocol_api/core/module.py
Original file line number Diff line number Diff line change
@@ -390,11 +390,14 @@ class AbstractFlexStackerCore(AbstractModuleCore):
def get_serial_number(self) -> str:
"""Get the module's unique hardware serial number."""

@abstractmethod
def set_static_mode(self, static: bool) -> None:
"""Set the Flex Stacker's static mode."""

@abstractmethod
def retrieve(self) -> None:
"""Release and return a labware at the bottom of the labware stack."""
"""Release a labware from the hopper to the staging slot."""

@abstractmethod
def store(self) -> None:
"""Store a labware at the bottom of the labware stack."""
pass
"""Store a labware in the stacker hopper."""
14 changes: 14 additions & 0 deletions api/src/opentrons/protocol_api/core/protocol.py
Original file line number Diff line number Diff line change
@@ -111,6 +111,20 @@ def load_lid(
"""Load an individual lid labware using its identifying parameters. Must be loaded on a labware."""
...

@abstractmethod
def load_labware_to_flex_stacker_hopper(
self,
module_core: ModuleCoreType,
load_name: str,
quantity: int,
label: Optional[str],
namespace: Optional[str],
version: Optional[int],
lid: Optional[str],
) -> None:
"""Load one or more labware with or without a lid to the flex stacker hopper."""
...

@abstractmethod
def move_labware(
self,
60 changes: 59 additions & 1 deletion api/src/opentrons/protocol_api/module_contexts.py
Original file line number Diff line number Diff line change
@@ -1112,21 +1112,79 @@ class FlexStackerContext(ModuleContext):

_core: FlexStackerCore

@requires_version(2, 23)
def load_labware_to_hopper(
self,
load_name: str,
quantity: int,
label: Optional[str] = None,
namespace: Optional[str] = None,
version: Optional[int] = None,
lid: Optional[str] = None,
) -> None:
"""Load one or more labware onto the flex stacker."""
self._protocol_core.load_labware_to_flex_stacker_hopper(
module_core=self._core,
load_name=load_name,
quantity=quantity,
label=label,
namespace=namespace,
version=version,
lid=lid,
)

@requires_version(2, 23)
def enter_static_mode(self) -> None:
"""Enter static mode.

In static mode, the Flex Stacker will not move labware between the hopper and
the deck, and can be used as a staging slot area.
"""
self._core.set_static_mode(static=True)

@requires_version(2, 23)
def exit_static_mode(self) -> None:
"""End static mode.

In static mode, the Flex Stacker will not move labware between the hopper and
the deck, and can be used as a staging slot area.
"""
self._core.set_static_mode(static=False)

@property
@requires_version(2, 23)
def serial_number(self) -> str:
"""Get the module's unique hardware serial number."""
return self._core.get_serial_number()

@requires_version(2, 23)
def retrieve(self) -> None:
def retrieve(self) -> Labware:
"""Release and return a labware at the bottom of the labware stack."""
self._core.retrieve()
labware_core = self._protocol_core.get_labware_on_module(self._core)
# the core retrieve command should have already raised the error
# if labware_core is None, this is just to satisfy the type checker
assert labware_core is not None, "Retrieve failed to return labware"
# check core map first
try:
labware = self._core_map.get(labware_core)
except KeyError:
# If the labware is not already in the core map,
# create a new Labware object
labware = Labware(
core=labware_core,
api_version=self._api_version,
protocol_core=self._protocol_core,
core_map=self._core_map,
)
self._core_map.add(labware_core, labware)
return labware

@requires_version(2, 23)
def store(self, labware: Labware) -> None:
"""Store a labware at the bottom of the labware stack.

:param labware: The labware object to store.
"""
assert labware._core is not None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this doing anything? It looks like labware._core is statically typechecked to never be None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so that user can call this function:

stacker.store(plate)

In a refactor, we will add the labware id as a store command param so we can make sure the labware has already been loaded on the stacker slot.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right but I mean, is the assert statement on line 1200 doing anything?

self._core.store()
28 changes: 26 additions & 2 deletions api/src/opentrons/protocol_api/protocol_context.py
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@
from opentrons.hardware_control.modules.types import (
MagneticBlockModel,
AbsorbanceReaderModel,
FlexStackerModuleModel,
)
from opentrons.legacy_commands import protocol_commands as cmds, types as cmd_types
from opentrons.legacy_commands.helpers import (
@@ -61,6 +62,7 @@
AbstractHeaterShakerCore,
AbstractMagneticBlockCore,
AbstractAbsorbanceReaderCore,
AbstractFlexStackerCore,
)
from .robot_context import RobotContext, HardwareManager
from .core.engine import ENGINE_CORE_API_VERSION
@@ -79,6 +81,7 @@
HeaterShakerContext,
MagneticBlockContext,
AbsorbanceReaderContext,
FlexStackerContext,
ModuleContext,
)
from ._parameters import Parameters
@@ -94,6 +97,7 @@
HeaterShakerContext,
MagneticBlockContext,
AbsorbanceReaderContext,
FlexStackerContext,
]


@@ -862,6 +866,9 @@ def load_module(

.. versionchanged:: 2.15
Added ``MagneticBlockContext`` return value.

.. versionchanged:: 2.23
Added ``FlexStackerModuleContext`` return value.
"""
if configuration:
if self._api_version < APIVersion(2, 4):
@@ -890,7 +897,18 @@ def load_module(
requested_model, AbsorbanceReaderModel
) and self._api_version < APIVersion(2, 21):
raise APIVersionError(
f"Module of type {module_name} is only available in versions 2.21 and above."
api_element=f"Module of type {module_name}",
until_version="2.21",
current_version=f"{self._api_version}",
)
if (
isinstance(requested_model, FlexStackerModuleModel)
and self._api_version < validation.FLEX_STACKER_VERSION_GATE
):
raise APIVersionError(
api_element=f"Module of type {module_name}",
until_version=str(validation.FLEX_STACKER_VERSION_GATE),
current_version=f"{self._api_version}",
)

deck_slot = (
@@ -901,7 +919,11 @@ def load_module(
)
)
if isinstance(deck_slot, StagingSlotName):
raise ValueError("Cannot load a module onto a staging slot.")
# flex stacker modules can only be loaded into staging slot inside a protocol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right layer to enforce this? We don't want it in the Protocol Engine loadModule command?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we had to do this because the flex stacker is the very first module that provides an addressable area (deck column 4) in a different slot than its cutout mount (column 3).

Technically we are still loading the module to column 3 of the deck in the engine (because the engine expects only deck slots). But to users, they wouldn't care which deck cutout the module is being loaded into. It would be weird have user load the module into column 3 in the protocol when the observable addressable area is in column 4.

We did this here so that we can keep the same engine behavior for the flex stacker for fast development. In a future refactor, we should use addressable areas as the load location on the context level, instead of physical deck slots.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'm only tenuously following, but I can catch up on this later. It doesn't seem like anything that should block this PR. Thanks for explaining!

if isinstance(requested_model, FlexStackerModuleModel):
deck_slot = validation.convert_flex_stacker_load_slot(deck_slot)
else:
raise ValueError(f"Cannot load {module_name} onto a staging slot.")

module_core = self._core.load_module(
model=requested_model,
@@ -1572,6 +1594,8 @@ def _create_module_context(
module_cls = MagneticBlockContext
elif isinstance(module_core, AbstractAbsorbanceReaderCore):
module_cls = AbsorbanceReaderContext
elif isinstance(module_core, AbstractFlexStackerCore):
module_cls = FlexStackerContext
else:
assert False, "Unsupported module type"

Loading
Loading