From 35686bc7e7c391d45a4a03573425afcd9030fc01 Mon Sep 17 00:00:00 2001 From: Alise Au <20424172+ahiuchingau@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:26:01 -0500 Subject: [PATCH] feat(api): Flex Stacker Module Support for EVT (#17300) Covers EXEC-967, EXEC-965, EXEC-946, EXEC-1078 This PR introduces the .store(), .retrieve(), and .load_labware_to_hopper(...) commands. These commands allow the declaration of labware inside the hopper, retrieval of a handleable labware core from the stacker, and storage of labware into the stacker. --- ..._PIPETTES_TM_ModuleInStagingAreaCol4].json | 8 +- api/src/opentrons/protocol_api/__init__.py | 2 + .../protocol_api/core/engine/deck_conflict.py | 3 + .../protocol_api/core/engine/module_core.py | 18 ++- .../protocol_api/core/engine/protocol.py | 30 +++++ .../core/legacy/legacy_protocol_core.py | 13 ++ api/src/opentrons/protocol_api/core/module.py | 9 +- .../opentrons/protocol_api/core/protocol.py | 14 ++ .../opentrons/protocol_api/module_contexts.py | 60 ++++++++- .../protocol_api/protocol_context.py | 28 +++- api/src/opentrons/protocol_api/validation.py | 24 ++++ .../commands/command_unions.py | 5 + .../commands/flex_stacker/__init__.py | 14 ++ .../commands/flex_stacker/configure.py | 76 +++++++++++ .../commands/flex_stacker/retrieve.py | 63 +++++++-- .../commands/flex_stacker/store.py | 45 +++++-- .../protocol_engine/commands/load_labware.py | 56 ++++++-- .../protocol_engine/commands/move_labware.py | 1 - .../module_substates/flex_stacker_substate.py | 40 +++++- .../protocol_engine/state/modules.py | 26 +++- .../protocol_engine/state/update_types.py | 73 +++++++++++ .../protocols/api_support/definitions.py | 2 +- .../core/engine/test_deck_conflict.py | 3 + .../core/engine/test_flex_stacker_core.py | 57 ++++++++ .../core/engine/test_protocol_core.py | 2 + .../protocol_api/test_flex_stacker_context.py | 124 ++++++++++++++++++ .../protocol_api/test_protocol_context.py | 52 +++++++- .../commands/flex_stacker/test_retrieve.py | 63 +++++++-- .../commands/flex_stacker/test_store.py | 67 ++++++++-- .../commands/test_load_labware.py | 87 ++++++++++++ .../commands/test_load_module.py | 8 ++ .../state/test_flex_stacker_state.py | 63 +++++++++ shared-data/command/schemas/11.json | 58 ++++++++ 33 files changed, 1126 insertions(+), 68 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/flex_stacker/configure.py create mode 100644 api/tests/opentrons/protocol_api/core/engine/test_flex_stacker_core.py create mode 100644 api/tests/opentrons/protocol_api/test_flex_stacker_context.py create mode 100644 api/tests/opentrons/protocol_engine/state/test_flex_stacker_state.py diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6126498df7][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6126498df7][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4].json index a2736f68c26..4e77b4410ee 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6126498df7][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6126498df7][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol4].json @@ -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 \"\", line N, in \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 \"\", line N, in \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", diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 41a061f5a94..2228aa3765c 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -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", diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index c56602a498c..121380d4a81 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -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, diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index c66a1a5459a..c5b59f30a58 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -700,8 +700,22 @@ 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, @@ -709,7 +723,7 @@ def retrieve(self) -> None: ) 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, diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index ce8449f70eb..e6dc505acaf 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -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() diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index 866b2e7bd12..cf1860d53f6 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -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 diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index d2583c711cb..f97fce62cf5 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -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.""" diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 3a35fdd824e..c323f3e8e27 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -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, diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index 82487196e42..7e8cc60a1f2 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -1112,6 +1112,45 @@ 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: @@ -1119,9 +1158,27 @@ def serial_number(self) -> str: 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: @@ -1129,4 +1186,5 @@ def store(self, labware: Labware) -> None: :param labware: The labware object to store. """ + assert labware._core is not None self._core.store() diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 84b42eefdae..1ea86aca893 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -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 + 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" diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index fde986c3552..2f2ba11a5ce 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -41,6 +41,7 @@ HeaterShakerModuleModel, MagneticBlockModel, AbsorbanceReaderModel, + FlexStackerModuleModel, ) from .disposal_locations import TrashBin, WasteChute @@ -58,6 +59,9 @@ # The first APIVersion where Python protocols can load lids as stacks and treat them as attributes of a parent labware. LID_STACK_VERSION_GATE = APIVersion(2, 23) +# The first APIVersion where Python protocols can use the Flex Stacker module. +FLEX_STACKER_VERSION_GATE = APIVersion(2, 23) + class InvalidPipetteMountError(ValueError): """An error raised when attempting to load pipettes on an invalid mount.""" @@ -389,6 +393,7 @@ def ensure_definition_is_not_lid_after_api_version( "heaterShakerModuleV1": HeaterShakerModuleModel.HEATER_SHAKER_V1, "magneticBlockV1": MagneticBlockModel.MAGNETIC_BLOCK_V1, "absorbanceReaderV1": AbsorbanceReaderModel.ABSORBANCE_READER_V1, + "flexStackerModuleV1": FlexStackerModuleModel.FLEX_STACKER_V1, } @@ -718,3 +723,22 @@ def ensure_valid_trash_location_for_transfer_v2( f" or `Well` (e.g. `reservoir.wells()[0]`) or an instance of `TrashBin` or `WasteChute`." f" However, it is '{trash_location}'." ) + + +def convert_flex_stacker_load_slot(slot_name: StagingSlotName) -> DeckSlotName: + """ + Ensure a Flex Stacker load location to a deck slot location. + + Args: + slot_name: The input staging slot location. + + Returns: + A `DeckSlotName` on the deck. + """ + _map = { + StagingSlotName.SLOT_A4: DeckSlotName.SLOT_A3, + StagingSlotName.SLOT_B4: DeckSlotName.SLOT_B3, + StagingSlotName.SLOT_C4: DeckSlotName.SLOT_C3, + StagingSlotName.SLOT_D4: DeckSlotName.SLOT_D3, + } + return _map[slot_name] diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 06a2160c75e..d6b4a043f77 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -431,6 +431,7 @@ absorbance_reader.OpenLid, absorbance_reader.Initialize, absorbance_reader.ReadAbsorbance, + flex_stacker.Configure, flex_stacker.Retrieve, flex_stacker.Store, calibration.CalibrateGripper, @@ -521,6 +522,7 @@ absorbance_reader.OpenLidParams, absorbance_reader.InitializeParams, absorbance_reader.ReadAbsorbanceParams, + flex_stacker.ConfigureParams, flex_stacker.RetrieveParams, flex_stacker.StoreParams, calibration.CalibrateGripperParams, @@ -609,6 +611,7 @@ absorbance_reader.OpenLidCommandType, absorbance_reader.InitializeCommandType, absorbance_reader.ReadAbsorbanceCommandType, + flex_stacker.ConfigureCommandType, flex_stacker.RetrieveCommandType, flex_stacker.StoreCommandType, calibration.CalibrateGripperCommandType, @@ -698,6 +701,7 @@ absorbance_reader.OpenLidCreate, absorbance_reader.InitializeCreate, absorbance_reader.ReadAbsorbanceCreate, + flex_stacker.ConfigureCreate, flex_stacker.RetrieveCreate, flex_stacker.StoreCreate, calibration.CalibrateGripperCreate, @@ -795,6 +799,7 @@ absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, absorbance_reader.ReadAbsorbanceResult, + flex_stacker.ConfigureResult, flex_stacker.RetrieveResult, flex_stacker.StoreResult, calibration.CalibrateGripperResult, diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/__init__.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/__init__.py index 9b31bfbbe5f..7507907ec95 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/__init__.py @@ -1,5 +1,13 @@ """Command models for Flex Stacker commands.""" +from .configure import ( + ConfigureCommandType, + ConfigureParams, + ConfigureResult, + Configure, + ConfigureCreate, +) + from .store import ( StoreCommandType, StoreParams, @@ -18,6 +26,12 @@ __all__ = [ + # flexStacker/configure + "ConfigureCommandType", + "ConfigureParams", + "ConfigureResult", + "Configure", + "ConfigureCreate", # flexStacker/store "StoreCommandType", "StoreParams", diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/configure.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/configure.py new file mode 100644 index 00000000000..85c0808b60a --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/configure.py @@ -0,0 +1,76 @@ +"""Command models to update configurations of a Flex Stacker.""" +from __future__ import annotations +from typing import Optional, Literal, TYPE_CHECKING +from typing_extensions import Type + +from pydantic import BaseModel, Field + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors import ( + ErrorOccurrence, +) +from ...state import update_types + +if TYPE_CHECKING: + from opentrons.protocol_engine.state.state import StateView + +ConfigureCommandType = Literal["flexStacker/configure"] + + +class ConfigureParams(BaseModel): + """Input parameters for a configure command.""" + + moduleId: str = Field( + ..., + description="Unique ID of the Flex Stacker.", + ) + static: Optional[bool] = Field( + None, + description="Whether the Flex Stacker should be in static mode.", + ) + + +class ConfigureResult(BaseModel): + """Result data from a configure command.""" + + +class ConfigureImpl(AbstractCommandImpl[ConfigureParams, SuccessData[ConfigureResult]]): + """Implementation of a configure command.""" + + def __init__( + self, + state_view: StateView, + **kwargs: object, + ) -> None: + self._state_view = state_view + + async def execute(self, params: ConfigureParams) -> SuccessData[ConfigureResult]: + """Execute the configurecommand.""" + stacker_state = self._state_view.modules.get_flex_stacker_substate( + params.moduleId + ) + state_update = update_types.StateUpdate() + if params.static is not None: + state_update.update_flex_stacker_mode( + module_id=stacker_state.module_id, static_mode=params.static + ) + return SuccessData(public=ConfigureResult(), state_update=state_update) + + +class Configure(BaseCommand[ConfigureParams, ConfigureResult, ErrorOccurrence]): + """A command to configure the Flex Stacker.""" + + commandType: ConfigureCommandType = "flexStacker/configure" + params: ConfigureParams + result: Optional[ConfigureResult] + + _ImplementationCls: Type[ConfigureImpl] = ConfigureImpl + + +class ConfigureCreate(BaseCommandCreate[ConfigureParams]): + """A request to execute a Flex Stacker Configure command.""" + + commandType: ConfigureCommandType = "flexStacker/configure" + params: ConfigureParams + + _CommandCls: Type[Configure] = Configure diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py index e561e628fb0..6b932322c0d 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py @@ -6,8 +6,13 @@ from pydantic import BaseModel, Field from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData -from ...errors.error_occurrence import ErrorOccurrence +from ...errors import ( + ErrorOccurrence, + CannotPerformModuleAction, + LocationIsOccupiedError, +) from ...state import update_types +from ...types import ModuleLocation if TYPE_CHECKING: from opentrons.protocol_engine.state.state import StateView @@ -28,6 +33,11 @@ class RetrieveParams(BaseModel): class RetrieveResult(BaseModel): """Result data from a labware retrieval command.""" + labware_id: str = Field( + ..., + description="The labware ID of the retrieved labware.", + ) + class RetrieveImpl(AbstractCommandImpl[RetrieveParams, SuccessData[RetrieveResult]]): """Implementation of a labware retrieval command.""" @@ -43,19 +53,54 @@ def __init__( async def execute(self, params: RetrieveParams) -> SuccessData[RetrieveResult]: """Execute the labware retrieval command.""" - state_update = update_types.StateUpdate() - stacker_substate = self._state_view.modules.get_flex_stacker_substate( - module_id=params.moduleId + stacker_state = self._state_view.modules.get_flex_stacker_substate( + params.moduleId ) + if stacker_state.in_static_mode: + raise CannotPerformModuleAction( + "Cannot retrieve labware from Flex Stacker while in static mode" + ) + + stacker_loc = ModuleLocation(moduleId=params.moduleId) # Allow propagation of ModuleNotAttachedError. - stacker = self._equipment.get_module_hardware_api(stacker_substate.module_id) + stacker_hw = self._equipment.get_module_hardware_api(stacker_state.module_id) + + if not stacker_state.hopper_labware_ids: + raise CannotPerformModuleAction( + f"Flex Stacker {params.moduleId} has no labware to retrieve" + ) - if stacker is not None: - # TODO: get labware height from labware state view - await stacker.dispense_labware(labware_height=50.0) + try: + self._state_view.labware.raise_if_labware_in_location(stacker_loc) + except LocationIsOccupiedError: + raise CannotPerformModuleAction( + "Cannot retrieve a labware from Flex Stacker if the carriage is occupied" + ) - return SuccessData(public=RetrieveResult(), state_update=state_update) + state_update = update_types.StateUpdate() + + # Get the labware dimensions for the labware being retrieved, + # which is the first one in the hopper labware id list + lw_id = stacker_state.hopper_labware_ids[0] + lw_dim = self._state_view.labware.get_dimensions(labware_id=lw_id) + + if stacker_hw is not None: + # Dispense the labware from the Flex Stacker using the labware height + await stacker_hw.dispense_labware(labware_height=lw_dim.z) + + # update the state to reflect the labware is now in the flex stacker slot + state_update.set_labware_location( + labware_id=lw_id, + new_location=ModuleLocation(moduleId=params.moduleId), + new_offset_id=None, + ) + state_update.retrieve_flex_stacker_labware( + module_id=params.moduleId, labware_id=lw_id + ) + return SuccessData( + public=RetrieveResult(labware_id=lw_id), state_update=state_update + ) class Retrieve(BaseCommand[RetrieveParams, RetrieveResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py index 918105d9c68..206c8ee59a9 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py @@ -6,8 +6,14 @@ from pydantic import BaseModel, Field from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData -from ...errors.error_occurrence import ErrorOccurrence +from ...errors import ( + ErrorOccurrence, + CannotPerformModuleAction, + LabwareNotLoadedOnModuleError, +) from ...state import update_types +from ...types import OFF_DECK_LOCATION + if TYPE_CHECKING: from opentrons.protocol_engine.state.state import StateView @@ -44,17 +50,40 @@ def __init__( async def execute(self, params: StoreParams) -> SuccessData[StoreResult]: """Execute the labware storage command.""" - state_update = update_types.StateUpdate() - stacker_substate = self._state_view.modules.get_flex_stacker_substate( - module_id=params.moduleId + stacker_state = self._state_view.modules.get_flex_stacker_substate( + params.moduleId ) + if stacker_state.in_static_mode: + raise CannotPerformModuleAction( + "Cannot store labware in Flex Stacker while in static mode" + ) # Allow propagation of ModuleNotAttachedError. - stacker = self._equipment.get_module_hardware_api(stacker_substate.module_id) + stacker_hw = self._equipment.get_module_hardware_api(stacker_state.module_id) + + try: + lw_id = self._state_view.labware.get_id_by_module(params.moduleId) + except LabwareNotLoadedOnModuleError: + raise CannotPerformModuleAction( + "Cannot store labware if Flex Stacker carriage is empty" + ) + + lw_dim = self._state_view.labware.get_dimensions(labware_id=lw_id) + # TODO: check the type of the labware should match that already in the stack + state_update = update_types.StateUpdate() + + if stacker_hw is not None: + await stacker_hw.store_labware(labware_height=lw_dim.z) - if stacker is not None: - # TODO: get labware height from labware state view - await stacker.store_labware(labware_height=50.0) + # update the state to reflect the labware is store in the stack + state_update.set_labware_location( + labware_id=lw_id, + new_location=OFF_DECK_LOCATION, + new_offset_id=None, + ) + state_update.store_flex_stacker_labware( + module_id=params.moduleId, labware_id=lw_id + ) return SuccessData(public=StoreResult(), state_update=state_update) diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index d0e83863616..f0d9f11b947 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema -from typing_extensions import Literal +from typing_extensions import Literal, TypeGuard from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -17,6 +17,8 @@ OnLabwareLocation, DeckSlotLocation, AddressableAreaLocation, + LoadedModule, + OFF_DECK_LOCATION, ) from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData @@ -108,7 +110,16 @@ def __init__( self._equipment = equipment self._state_view = state_view - async def execute( + def _is_loading_to_module( + self, location: LabwareLocation, module_model: ModuleModel + ) -> TypeGuard[ModuleLocation]: + if not isinstance(location, ModuleLocation): + return False + + module: LoadedModule = self._state_view.modules.get(location.moduleId) + return module.model == module_model + + async def execute( # noqa: C901 self, params: LoadLabwareParams ) -> SuccessData[LoadLabwareResult]: """Load definition and calibration data necessary for a labware.""" @@ -145,9 +156,24 @@ async def execute( ) state_update.set_addressable_area_used(params.location.slotName.id) - verified_location = self._state_view.geometry.ensure_location_not_occupied( - params.location - ) + verified_location: LabwareLocation + if ( + self._is_loading_to_module( + params.location, ModuleModel.FLEX_STACKER_MODULE_V1 + ) + and not self._state_view.modules.get_flex_stacker_substate( + params.location.moduleId + ).in_static_mode + ): + # labware loaded to the flex stacker hopper is considered offdeck. This is + # a temporary solution until the hopper can be represented as non-addressable + # addressable area in the deck configuration. + verified_location = OFF_DECK_LOCATION + else: + verified_location = self._state_view.geometry.ensure_location_not_occupied( + params.location + ) + loaded_labware = await self._equipment.load_labware( load_name=params.loadName, namespace=params.namespace, @@ -186,12 +212,20 @@ async def execute( ) # Validate labware for the absorbance reader - elif isinstance(params.location, ModuleLocation): - module = self._state_view.modules.get(params.location.moduleId) - if module is not None and module.model == ModuleModel.ABSORBANCE_READER_V1: - self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( - loaded_labware.definition - ) + if self._is_loading_to_module( + params.location, ModuleModel.ABSORBANCE_READER_V1 + ): + self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( + loaded_labware.definition + ) + + if self._is_loading_to_module( + params.location, ModuleModel.FLEX_STACKER_MODULE_V1 + ): + state_update.load_flex_stacker_hopper_labware( + module_id=params.location.moduleId, + labware_id=loaded_labware.labware_id, + ) return SuccessData( public=LoadLabwareResult( labwareId=loaded_labware.labware_id, diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index e5db5256dba..afbc830ea66 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -273,7 +273,6 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C raise LabwareMovementNotAllowedError( f"Cannot move adapter '{current_labware_definition.parameters.loadName}' with gripper." ) - validated_current_loc = ( self._state_view.geometry.ensure_valid_gripper_location( current_labware.location diff --git a/api/src/opentrons/protocol_engine/state/module_substates/flex_stacker_substate.py b/api/src/opentrons/protocol_engine/state/module_substates/flex_stacker_substate.py index 67690a0750a..deb35277e85 100644 --- a/api/src/opentrons/protocol_engine/state/module_substates/flex_stacker_substate.py +++ b/api/src/opentrons/protocol_engine/state/module_substates/flex_stacker_substate.py @@ -1,6 +1,13 @@ """Flex Stacker substate.""" from dataclasses import dataclass -from typing import NewType +from typing import NewType, List +from opentrons.protocol_engine.state.update_types import ( + FlexStackerStateUpdate, + FlexStackerLoadHopperLabware, + FlexStackerRetrieveLabware, + FlexStackerStoreLabware, + NO_CHANGE, +) FlexStackerId = NewType("FlexStackerId", str) @@ -15,3 +22,34 @@ class FlexStackerSubState: """ module_id: FlexStackerId + in_static_mode: bool + hopper_labware_ids: List[str] + + def new_from_state_change( + self, update: FlexStackerStateUpdate + ) -> "FlexStackerSubState": + """Return a new state with the given update applied.""" + new_mode = self.in_static_mode + if update.in_static_mode != NO_CHANGE: + new_mode = update.in_static_mode + + lw_change = update.hopper_labware_update + new_labware_ids = self.hopper_labware_ids.copy() + + if lw_change != NO_CHANGE: + # TODO the labware stack needs to be handled more elegantly + # this is a temporary solution to enable evt testing + if isinstance(lw_change, FlexStackerLoadHopperLabware): + # for manually loading labware in the stacker + new_labware_ids.append(lw_change.labware_id) + elif isinstance(lw_change, FlexStackerRetrieveLabware): + new_labware_ids.remove(lw_change.labware_id) + elif isinstance(lw_change, FlexStackerStoreLabware): + # automatically store labware at the bottom of the stack + new_labware_ids.insert(0, lw_change.labware_id) + + return FlexStackerSubState( + module_id=self.module_id, + hopper_labware_ids=new_labware_ids, + in_static_mode=new_mode, + ) diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 046f57ffc94..76d7a084b42 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -371,6 +371,8 @@ def _add_module_substate( elif ModuleModel.is_flex_stacker(actual_model): self._state.substate_by_module_id[module_id] = FlexStackerSubState( module_id=FlexStackerId(module_id), + in_static_mode=False, + hopper_labware_ids=[], ) def _update_additional_slots_occupied_by_thermocycler( @@ -614,11 +616,18 @@ def _handle_absorbance_reader_commands( ) def _handle_flex_stacker_commands( - self, flex_stacker_state_update: FlexStackerStateUpdate + self, state_update: FlexStackerStateUpdate ) -> None: """Handle Flex Stacker state updates.""" - # TODO: Implement Flex Stacker state updates - pass + module_id = state_update.module_id + prev_substate = self._state.substate_by_module_id[module_id] + assert isinstance( + prev_substate, FlexStackerSubState + ), f"{module_id} is not a Flex Stacker." + + self._state.substate_by_module_id[ + module_id + ] = prev_substate.new_from_state_change(state_update) class ModuleView: @@ -1216,7 +1225,10 @@ def raise_if_module_in_location( ) -> None: """Raise if the given location has a module in it.""" for module in self.get_all(): - if module.location == location: + if ( + module.location == location + and module.model != ModuleModel.FLEX_STACKER_MODULE_V1 + ): raise errors.LocationIsOccupiedError( f"Module {module.model} is already present at {location}." ) @@ -1319,9 +1331,9 @@ def ensure_and_convert_module_fixture_location( assert deck_slot.value[-1] == "3" return f"absorbanceReaderV1{deck_slot.value}" elif model == ModuleModel.FLEX_STACKER_MODULE_V1: - # only allowed in column 4 - assert deck_slot.value[-1] == "4" - return f"flexStackerModuleV1{deck_slot.value}" + # loaded to column 3 but the addressable area is in column 4 + assert deck_slot.value[-1] == "3" + return f"flexStackerModuleV1{deck_slot.value[0]}4" raise ValueError( f"Unknown module {model.name} has no addressable areas to provide." diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 1bd15979223..5a0f79786b1 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -313,11 +313,36 @@ class AbsorbanceReaderStateUpdate: ) +@dataclasses.dataclass +class FlexStackerLoadHopperLabware: + """An update to the Flex Stacker module static state.""" + + labware_id: str + + +@dataclasses.dataclass +class FlexStackerRetrieveLabware: + """An update to the Flex Stacker module static state.""" + + labware_id: str + + +@dataclasses.dataclass +class FlexStackerStoreLabware: + """An update to the Flex Stacker module static state.""" + + labware_id: str + + @dataclasses.dataclass class FlexStackerStateUpdate: """An update to the Flex Stacker module state.""" module_id: str + in_static_mode: bool | NoChangeType = NO_CHANGE + hopper_labware_update: FlexStackerLoadHopperLabware | FlexStackerRetrieveLabware | FlexStackerStoreLabware | NoChangeType = ( + NO_CHANGE + ) @dataclasses.dataclass @@ -721,3 +746,51 @@ def set_addressable_area_used(self: Self, addressable_area_name: str) -> Self: addressable_area_name=addressable_area_name ) return self + + def load_flex_stacker_hopper_labware( + self, + module_id: str, + labware_id: str, + ) -> Self: + """Add a labware definition to the engine.""" + self.flex_stacker_state_update = FlexStackerStateUpdate( + module_id=module_id, + hopper_labware_update=FlexStackerLoadHopperLabware(labware_id=labware_id), + ) + return self + + def retrieve_flex_stacker_labware( + self, + module_id: str, + labware_id: str, + ) -> Self: + """Add a labware definition to the engine.""" + self.flex_stacker_state_update = FlexStackerStateUpdate( + module_id=module_id, + hopper_labware_update=FlexStackerRetrieveLabware(labware_id=labware_id), + ) + return self + + def store_flex_stacker_labware( + self, + module_id: str, + labware_id: str, + ) -> Self: + """Add a labware definition to the engine.""" + self.flex_stacker_state_update = FlexStackerStateUpdate( + module_id=module_id, + hopper_labware_update=FlexStackerStoreLabware(labware_id=labware_id), + ) + return self + + def update_flex_stacker_mode( + self, + module_id: str, + static_mode: bool, + ) -> Self: + """Update the mode of the Flex Stacker.""" + self.flex_stacker_state_update = FlexStackerStateUpdate( + module_id=module_id, + in_static_mode=static_mode, + ) + return self diff --git a/api/src/opentrons/protocols/api_support/definitions.py b/api/src/opentrons/protocols/api_support/definitions.py index e2f6aee1a2a..a353e1d49fe 100644 --- a/api/src/opentrons/protocols/api_support/definitions.py +++ b/api/src/opentrons/protocols/api_support/definitions.py @@ -1,6 +1,6 @@ from .types import APIVersion -MAX_SUPPORTED_VERSION = APIVersion(2, 22) +MAX_SUPPORTED_VERSION = APIVersion(2, 23) """The maximum supported protocol API version in this release.""" MIN_SUPPORTED_VERSION = APIVersion(2, 0) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 208ac843b94..fa5900806ce 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -280,6 +280,9 @@ def test_maps_different_module_models( decoy: Decoy, mock_state_view: StateView, module_model: ModuleModel ) -> None: """It should correctly map all possible kinds of hardware module.""" + # TODO: skipping flex stacker check for now to enable evt + if module_model is ModuleModel.FLEX_STACKER_MODULE_V1: + pytest.skip("Flex stacker check not implemented yet") def get_expected_mapping_result() -> wrapped_deck_conflict.DeckItem: expected_name_for_errors = module_model.value diff --git a/api/tests/opentrons/protocol_api/core/engine/test_flex_stacker_core.py b/api/tests/opentrons/protocol_api/core/engine/test_flex_stacker_core.py new file mode 100644 index 00000000000..deb2f8a86a8 --- /dev/null +++ b/api/tests/opentrons/protocol_api/core/engine/test_flex_stacker_core.py @@ -0,0 +1,57 @@ +"""Tests for Flex Stacker Engine Core.""" +import pytest +from decoy import Decoy + +from opentrons.hardware_control import SynchronousAdapter +from opentrons.hardware_control.modules import FlexStacker +from opentrons.hardware_control.modules.types import ( + ModuleType, +) +from opentrons.protocol_engine.clients import SyncClient as EngineClient +from opentrons.protocol_api.core.engine.module_core import FlexStackerCore +from opentrons.protocol_api import MAX_SUPPORTED_VERSION + +SyncFlexStackerHardware = SynchronousAdapter[FlexStacker] + + +@pytest.fixture +def mock_engine_client(decoy: Decoy) -> EngineClient: + """Get a mock ProtocolEngine synchronous client.""" + return decoy.mock(cls=EngineClient) + + +@pytest.fixture +def mock_sync_module_hardware(decoy: Decoy) -> SyncFlexStackerHardware: + """Get a mock synchronous module hardware.""" + return decoy.mock(name="SyncFlexStackerHardware") # type: ignore[no-any-return] + + +@pytest.fixture +def subject( + mock_engine_client: EngineClient, + mock_sync_module_hardware: SyncFlexStackerHardware, +) -> FlexStackerCore: + """Get a Flex Stacker Core test subject.""" + return FlexStackerCore( + module_id="1234", + engine_client=mock_engine_client, + api_version=MAX_SUPPORTED_VERSION, + sync_module_hardware=mock_sync_module_hardware, + ) + + +def test_create( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_sync_module_hardware: SyncFlexStackerHardware, +) -> None: + """It should be able to create a Flex Stacker module core.""" + result = FlexStackerCore( + module_id="1234", + engine_client=mock_engine_client, + api_version=MAX_SUPPORTED_VERSION, + sync_module_hardware=mock_sync_module_hardware, + ) + + assert result.module_id == "1234" + assert result.MODULE_TYPE == ModuleType.FLEX_STACKER diff --git a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py index 2889a47cea9..8fffba98c54 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py @@ -34,6 +34,7 @@ ThermocyclerModuleModel, HeaterShakerModuleModel, MagneticBlockModel, + FlexStackerModuleModel, ) from opentrons.protocol_engine import ( ModuleModel as EngineModuleModel, @@ -1613,6 +1614,7 @@ def test_load_module_thermocycler_with_no_location( MagneticModuleModel.MAGNETIC_V2, TemperatureModuleModel.TEMPERATURE_V1, TemperatureModuleModel.TEMPERATURE_V2, + FlexStackerModuleModel.FLEX_STACKER_V1, ], ) def test_load_module_no_location( diff --git a/api/tests/opentrons/protocol_api/test_flex_stacker_context.py b/api/tests/opentrons/protocol_api/test_flex_stacker_context.py new file mode 100644 index 00000000000..c514ed6e4bb --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_flex_stacker_context.py @@ -0,0 +1,124 @@ +"""Tests for Protocol API Flex Stacker contexts.""" +import pytest +from decoy import Decoy + +from opentrons.legacy_broker import LegacyBroker +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocol_api import FlexStackerContext +from opentrons.protocol_api.core.common import ( + ProtocolCore, + LabwareCore, + FlexStackerCore, +) +from opentrons.protocol_api.core.core_map import LoadedCoreMap + + +@pytest.fixture +def mock_core(decoy: Decoy) -> FlexStackerCore: + """Get a mock module implementation core.""" + return decoy.mock(cls=FlexStackerCore) + + +@pytest.fixture +def mock_protocol_core(decoy: Decoy) -> ProtocolCore: + """Get a mock protocol implementation core.""" + return decoy.mock(cls=ProtocolCore) + + +@pytest.fixture +def mock_labware_core(decoy: Decoy) -> LabwareCore: + """Get a mock labware implementation core.""" + mock_core = decoy.mock(cls=LabwareCore) + decoy.when(mock_core.get_well_columns()).then_return([]) + return mock_core + + +@pytest.fixture +def mock_core_map(decoy: Decoy) -> LoadedCoreMap: + """Get a mock LoadedCoreMap.""" + return decoy.mock(cls=LoadedCoreMap) + + +@pytest.fixture +def mock_broker(decoy: Decoy) -> LegacyBroker: + """Get a mock command message broker.""" + return decoy.mock(cls=LegacyBroker) + + +@pytest.fixture +def api_version() -> APIVersion: + """Get an API version to apply to the interface.""" + return APIVersion(2, 23) + + +@pytest.fixture +def subject( + api_version: APIVersion, + mock_core: FlexStackerCore, + mock_protocol_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + mock_broker: LegacyBroker, +) -> FlexStackerContext: + """Get an absorbance reader context with its dependencies mocked out.""" + return FlexStackerContext( + core=mock_core, + protocol_core=mock_protocol_core, + core_map=mock_core_map, + broker=mock_broker, + api_version=api_version, + ) + + +def test_get_serial_number( + decoy: Decoy, mock_core: FlexStackerCore, subject: FlexStackerContext +) -> None: + """It should get the serial number from the core.""" + decoy.when(mock_core.get_serial_number()).then_return("12345") + result = subject.serial_number + assert result == "12345" + + +def test_load_labware_to_hopper( + decoy: Decoy, + mock_core: FlexStackerCore, + mock_protocol_core: ProtocolCore, + subject: FlexStackerContext, +) -> None: + """It should create two labware to the core map.""" + subject.load_labware_to_hopper(load_name="some-load-name", quantity=2) + decoy.verify( + mock_protocol_core.load_labware_to_flex_stacker_hopper( + module_core=mock_core, + load_name="some-load-name", + quantity=2, + label=None, + namespace=None, + version=None, + lid=None, + ), + times=1, + ) + + +def test_load_labware_with_lid_to_hopper( + decoy: Decoy, + mock_core: FlexStackerCore, + mock_protocol_core: ProtocolCore, + subject: FlexStackerContext, +) -> None: + """It should create two labware to the core map.""" + subject.load_labware_to_hopper( + load_name="some-load-name", quantity=2, lid="some-lid-name" + ) + decoy.verify( + mock_protocol_core.load_labware_to_flex_stacker_hopper( + module_core=mock_core, + load_name="some-load-name", + quantity=2, + label=None, + namespace=None, + version=None, + lid="some-lid-name", + ), + times=1, + ) diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 28ac0c014e0..8e944d62cac 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -14,7 +14,11 @@ from opentrons.config import feature_flags as ff from opentrons.protocol_api import OFF_DECK from opentrons.legacy_broker import LegacyBroker -from opentrons.hardware_control.modules.types import ModuleType, TemperatureModuleModel +from opentrons.hardware_control.modules.types import ( + ModuleType, + TemperatureModuleModel, + FlexStackerModuleModel, +) from opentrons.protocols.api_support import instrument as mock_instrument_support from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import ( @@ -44,6 +48,7 @@ TemperatureModuleCore, MagneticModuleCore, MagneticBlockCore, + FlexStackerCore, ) from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.protocols.api_support.deck_type import ( @@ -1297,10 +1302,53 @@ def test_load_module_on_staging_slot_raises( mock_validation.ensure_and_convert_deck_slot(42, api_version, "OT-3 Standard") ).then_return(StagingSlotName.SLOT_B4) - with pytest.raises(ValueError, match="Cannot load a module onto a staging slot."): + with pytest.raises( + ValueError, match="Cannot load spline reticulator onto a staging slot." + ): subject.load_module(module_name="spline reticulator", location=42) +def test_load_flex_stacker_on_staging_slot( + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + api_version: APIVersion, + subject: ProtocolContext, +) -> None: + """It should load a module.""" + mock_module_core: FlexStackerCore = decoy.mock(cls=FlexStackerCore) + + decoy.when(mock_core.robot_type).then_return("OT-3 Standard") + decoy.when(mock_validation.ensure_module_model("flexStackerModuleV1")).then_return( + FlexStackerModuleModel.FLEX_STACKER_V1 + ) + decoy.when( + mock_validation.ensure_and_convert_deck_slot("B4", api_version, "OT-3 Standard") + ).then_return(StagingSlotName.SLOT_B4) + decoy.when( + mock_validation.convert_flex_stacker_load_slot(StagingSlotName.SLOT_B4) + ).then_return(DeckSlotName.SLOT_B3) + + decoy.when( + mock_core.load_module( + model=FlexStackerModuleModel.FLEX_STACKER_V1, + deck_slot=DeckSlotName.SLOT_B3, + configuration=None, + ) + ).then_return(mock_module_core) + + decoy.when(mock_module_core.get_model()).then_return( + FlexStackerModuleModel.FLEX_STACKER_V1 + ) + decoy.when(mock_module_core.get_serial_number()).then_return("cap'n crunch") + decoy.when(mock_module_core.get_deck_slot()).then_return(DeckSlotName.SLOT_B3) + + result = subject.load_module(module_name="flexStackerModuleV1", location="B4") + + assert isinstance(result, ModuleContext) + decoy.verify(mock_core_map.add(mock_module_core, result), times=1) + + def test_loaded_modules( decoy: Decoy, mock_core_map: LoadedCoreMap, diff --git a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_retrieve.py b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_retrieve.py index 2a2eda85375..533f99ce2fd 100644 --- a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_retrieve.py +++ b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_retrieve.py @@ -1,9 +1,18 @@ """Test Flex Stacker retrieve command implementation.""" from decoy import Decoy +import pytest +from contextlib import nullcontext as does_not_raise +from typing import ContextManager, Any from opentrons.hardware_control.modules import FlexStacker from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.state.update_types import ( + StateUpdate, + FlexStackerStateUpdate, + FlexStackerRetrieveLabware, + LabwareLocationUpdate, +) from opentrons.protocol_engine.state.module_substates import ( FlexStackerSubState, FlexStackerId, @@ -12,34 +21,72 @@ from opentrons.protocol_engine.commands import flex_stacker from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands.flex_stacker.retrieve import RetrieveImpl +from opentrons.protocol_engine.types import Dimensions, ModuleLocation +from opentrons.protocol_engine.errors import CannotPerformModuleAction +@pytest.mark.parametrize( + "in_static_mode,expectation", + [ + ( + True, + pytest.raises( + CannotPerformModuleAction, + match="Cannot retrieve labware from Flex Stacker while in static mode", + ), + ), + (False, does_not_raise()), + ], +) async def test_retrieve( decoy: Decoy, state_view: StateView, equipment: EquipmentHandler, + in_static_mode: bool, + expectation: ContextManager[Any], ) -> None: """It should be able to retrieve a labware.""" subject = RetrieveImpl(state_view=state_view, equipment=equipment) data = flex_stacker.RetrieveParams(moduleId="flex-stacker-id") - fs_module_substate = decoy.mock(cls=FlexStackerSubState) + fs_module_substate = FlexStackerSubState( + module_id=FlexStackerId("flex-stacker-id"), + in_static_mode=in_static_mode, + hopper_labware_ids=["labware-id"], + ) fs_hardware = decoy.mock(cls=FlexStacker) decoy.when( state_view.modules.get_flex_stacker_substate(module_id="flex-stacker-id") ).then_return(fs_module_substate) - decoy.when(fs_module_substate.module_id).then_return( - FlexStackerId("flex-stacker-id") + decoy.when(state_view.labware.get_dimensions(labware_id="labware-id")).then_return( + Dimensions(x=1, y=1, z=1) ) decoy.when( equipment.get_module_hardware_api(FlexStackerId("flex-stacker-id")) ).then_return(fs_hardware) - result = await subject.execute(data) - decoy.verify(await fs_hardware.dispense_labware(labware_height=50.0), times=1) - assert result == SuccessData( - public=flex_stacker.RetrieveResult(), - ) + with expectation: + result = await subject.execute(data) + + if not in_static_mode: + decoy.verify(await fs_hardware.dispense_labware(labware_height=1), times=1) + + assert result == SuccessData( + public=flex_stacker.RetrieveResult(labware_id="labware-id"), + state_update=StateUpdate( + labware_location=LabwareLocationUpdate( + labware_id="labware-id", + new_location=ModuleLocation(moduleId="flex-stacker-id"), + offset_id=None, + ), + flex_stacker_state_update=FlexStackerStateUpdate( + module_id="flex-stacker-id", + hopper_labware_update=FlexStackerRetrieveLabware( + labware_id="labware-id" + ), + ), + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py index e12bde858c2..f7eaf9b4eb9 100644 --- a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py +++ b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py @@ -1,8 +1,18 @@ """Test Flex Stacker store command implementation.""" from decoy import Decoy +import pytest +from contextlib import nullcontext as does_not_raise +from typing import ContextManager, Any from opentrons.hardware_control.modules import FlexStacker +from opentrons.protocol_engine.state.update_types import ( + StateUpdate, + FlexStackerStateUpdate, + FlexStackerStoreLabware, + LabwareLocationUpdate, +) + from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.state.module_substates import ( FlexStackerSubState, @@ -12,34 +22,75 @@ from opentrons.protocol_engine.commands import flex_stacker from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands.flex_stacker.store import StoreImpl +from opentrons.protocol_engine.types import Dimensions, OFF_DECK_LOCATION +from opentrons.protocol_engine.errors import CannotPerformModuleAction +@pytest.mark.parametrize( + "in_static_mode,expectation", + [ + ( + True, + pytest.raises( + CannotPerformModuleAction, + match="Cannot store labware in Flex Stacker while in static mode", + ), + ), + (False, does_not_raise()), + ], +) async def test_store( decoy: Decoy, state_view: StateView, equipment: EquipmentHandler, + in_static_mode: bool, + expectation: ContextManager[Any], ) -> None: """It should be able to store a labware.""" subject = StoreImpl(state_view=state_view, equipment=equipment) data = flex_stacker.StoreParams(moduleId="flex-stacker-id") - fs_module_substate = decoy.mock(cls=FlexStackerSubState) + fs_module_substate = FlexStackerSubState( + module_id=FlexStackerId("flex-stacker-id"), + in_static_mode=in_static_mode, + hopper_labware_ids=["labware-id"], + ) fs_hardware = decoy.mock(cls=FlexStacker) decoy.when( state_view.modules.get_flex_stacker_substate(module_id="flex-stacker-id") ).then_return(fs_module_substate) - decoy.when(fs_module_substate.module_id).then_return( - FlexStackerId("flex-stacker-id") + decoy.when( + state_view.labware.get_id_by_module(module_id="flex-stacker-id") + ).then_return("labware-id") + + decoy.when(state_view.labware.get_dimensions(labware_id="labware-id")).then_return( + Dimensions(x=1, y=1, z=1) ) decoy.when( equipment.get_module_hardware_api(FlexStackerId("flex-stacker-id")) ).then_return(fs_hardware) - result = await subject.execute(data) - decoy.verify(await fs_hardware.store_labware(labware_height=50.0), times=1) - assert result == SuccessData( - public=flex_stacker.StoreResult(), - ) + with expectation: + result = await subject.execute(data) + + if not in_static_mode: + decoy.verify(await fs_hardware.store_labware(labware_height=1), times=1) + assert result == SuccessData( + public=flex_stacker.StoreResult(), + state_update=StateUpdate( + labware_location=LabwareLocationUpdate( + labware_id="labware-id", + new_location=OFF_DECK_LOCATION, + offset_id=None, + ), + flex_stacker_state_update=FlexStackerStateUpdate( + module_id="flex-stacker-id", + hopper_labware_update=FlexStackerStoreLabware( + labware_id="labware-id" + ), + ), + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py index c8cdcbec147..01a5406731d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py @@ -20,6 +20,10 @@ DeckSlotLocation, LabwareLocation, OnLabwareLocation, + ModuleLocation, + ModuleModel, + LoadedModule, + OFF_DECK_LOCATION, ) from opentrons.protocol_engine.execution import LoadedLabwareData, EquipmentHandler from opentrons.protocol_engine.resources import labware_validation @@ -27,8 +31,14 @@ from opentrons.protocol_engine.state.update_types import ( AddressableAreaUsedUpdate, LoadedLabwareUpdate, + FlexStackerStateUpdate, + FlexStackerLoadHopperLabware, StateUpdate, ) +from opentrons.protocol_engine.state.module_substates import ( + FlexStackerSubState, + FlexStackerId, +) from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands.load_labware import ( @@ -235,3 +245,80 @@ async def test_load_labware_raises_if_location_occupied( with pytest.raises(LocationIsOccupiedError): await subject.execute(data) + + +@pytest.mark.parametrize("display_name", ["My custom display name", None]) +async def test_load_labware_in_flex_stacker( + decoy: Decoy, + well_plate_def: LabwareDefinition, + equipment: EquipmentHandler, + state_view: StateView, + display_name: Optional[str], +) -> None: + """A LoadLabware command should have an execution implementation.""" + subject = LoadLabwareImplementation(equipment=equipment, state_view=state_view) + + data = LoadLabwareParams( + location=ModuleLocation(moduleId="some-module-id"), + loadName="some-load-name", + namespace="opentrons-test", + version=1, + displayName=display_name, + ) + + decoy.when(state_view.modules.get("some-module-id")).then_return( + LoadedModule( + id="some-module-id", + model=ModuleModel.FLEX_STACKER_MODULE_V1, + ) + ) + decoy.when( + state_view.modules.get_flex_stacker_substate("some-module-id") + ).then_return( + FlexStackerSubState( + module_id=FlexStackerId("some-module-id"), + in_static_mode=False, + hopper_labware_ids=[], + ) + ) + + decoy.when( + await equipment.load_labware( + location=OFF_DECK_LOCATION, + load_name="some-load-name", + namespace="opentrons-test", + version=1, + labware_id=None, + ) + ).then_return( + LoadedLabwareData( + labware_id="labware-id", + definition=well_plate_def, + offsetId="labware-offset-id", + ) + ) + + result = await subject.execute(data) + + assert result == SuccessData( + public=LoadLabwareResult( + labwareId="labware-id", + definition=well_plate_def, + offsetId="labware-offset-id", + ), + state_update=StateUpdate( + loaded_labware=LoadedLabwareUpdate( + labware_id="labware-id", + definition=well_plate_def, + offset_id="labware-offset-id", + new_location=OFF_DECK_LOCATION, + display_name=display_name, + ), + flex_stacker_state_update=FlexStackerStateUpdate( + module_id="some-module-id", + hopper_labware_update=FlexStackerLoadHopperLabware( + labware_id="labware-id" + ), + ), + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_module.py b/api/tests/opentrons/protocol_engine/commands/test_load_module.py index ae121e9adab..e540f24d0ef 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_module.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_module.py @@ -30,6 +30,7 @@ MagneticModuleModel, ThermocyclerModuleModel, HeaterShakerModuleModel, + FlexStackerModuleModel, ) from opentrons_shared_data.deck.types import ( DeckDefinitionV5, @@ -220,6 +221,13 @@ async def test_load_module_raises_if_location_occupied( DeckSlotName.SLOT_A2, "OT-3 Standard", ), + ( + FlexStackerModuleModel.FLEX_STACKER_V1, + EngineModuleModel.FLEX_STACKER_MODULE_V1, + load_deck(STANDARD_OT3_DECK, 5), + DeckSlotName.SLOT_A2, + "OT-3 Standard", + ), ], ) async def test_load_module_raises_wrong_location( diff --git a/api/tests/opentrons/protocol_engine/state/test_flex_stacker_state.py b/api/tests/opentrons/protocol_engine/state/test_flex_stacker_state.py new file mode 100644 index 00000000000..75fddf8dd3e --- /dev/null +++ b/api/tests/opentrons/protocol_engine/state/test_flex_stacker_state.py @@ -0,0 +1,63 @@ +"""Tests for the module state store handling flex stacker state.""" +import pytest + +from opentrons.protocol_engine.state.modules import ModuleStore, ModuleView +from opentrons.protocol_engine.state.module_substates import ( + FlexStackerSubState, + FlexStackerId, +) +from opentrons.protocol_engine.state.config import Config + +from opentrons.protocol_engine import actions +from opentrons.protocol_engine.types import DeckType, ModuleDefinition +import opentrons.protocol_engine.errors as errors + + +@pytest.fixture +def ot3_state_config() -> Config: + """Get a ProtocolEngine state config for the Flex.""" + return Config( + robot_type="OT-3 Standard", + deck_type=DeckType.OT3_STANDARD, + ) + + +@pytest.fixture +def subject( + ot3_state_config: Config, +) -> ModuleStore: + """Get a ModuleStore for the flex.""" + return ModuleStore(config=ot3_state_config, deck_fixed_labware=[]) + + +@pytest.fixture +def module_view(subject: ModuleStore) -> ModuleView: + """Get a ModuleView for the ModuleStore.""" + return ModuleView(state=subject._state) + + +def test_add_module_action( + subject: ModuleStore, + module_view: ModuleView, + flex_stacker_v1_def: ModuleDefinition, +) -> None: + """It should create a flex stacker substate.""" + action = actions.AddModuleAction( + module_id="someModuleId", + serial_number="someSerialNumber", + definition=flex_stacker_v1_def, + module_live_data={"status": "idle", "data": {}}, + ) + + with pytest.raises(errors.ModuleNotLoadedError): + module_view.get_flex_stacker_substate("someModuleId") + + subject.handle_action(action) + + result = module_view.get_flex_stacker_substate("someModuleId") + + assert result == FlexStackerSubState( + module_id=FlexStackerId("someModuleId"), + in_static_mode=False, + hopper_labware_ids=[], + ) diff --git a/shared-data/command/schemas/11.json b/shared-data/command/schemas/11.json index b70724f23cc..6521c1cd0ee 100644 --- a/shared-data/command/schemas/11.json +++ b/shared-data/command/schemas/11.json @@ -744,6 +744,34 @@ "title": "CommentParams", "type": "object" }, + "ConfigureCreate": { + "description": "A request to execute a Flex Stacker Configure command.", + "properties": { + "commandType": { + "const": "flexStacker/configure", + "default": "flexStacker/configure", + "enum": ["flexStacker/configure"], + "title": "Commandtype", + "type": "string" + }, + "intent": { + "$ref": "#/$defs/CommandIntent", + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "title": "Intent" + }, + "key": { + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "title": "Key", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ConfigureParams" + } + }, + "required": ["params"], + "title": "ConfigureCreate", + "type": "object" + }, "ConfigureForVolumeCreate": { "description": "Configure for volume command creation request model.", "properties": { @@ -857,6 +885,32 @@ "title": "ConfigureNozzleLayoutParams", "type": "object" }, + "ConfigureParams": { + "description": "Input parameters for a configure command.", + "properties": { + "moduleId": { + "description": "Unique ID of the Flex Stacker.", + "title": "Moduleid", + "type": "string" + }, + "static": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the Flex Stacker should be in static mode.", + "title": "Static" + } + }, + "required": ["moduleId"], + "title": "ConfigureParams", + "type": "object" + }, "Coordinate": { "description": "Three-dimensional coordinates.", "properties": { @@ -5837,6 +5891,7 @@ "dispenseInPlace": "#/$defs/DispenseInPlaceCreate", "dropTip": "#/$defs/DropTipCreate", "dropTipInPlace": "#/$defs/DropTipInPlaceCreate", + "flexStacker/configure": "#/$defs/ConfigureCreate", "flexStacker/retrieve": "#/$defs/RetrieveCreate", "flexStacker/store": "#/$defs/StoreCreate", "getNextTip": "#/$defs/GetNextTipCreate", @@ -6110,6 +6165,9 @@ { "$ref": "#/$defs/ReadAbsorbanceCreate" }, + { + "$ref": "#/$defs/ConfigureCreate" + }, { "$ref": "#/$defs/RetrieveCreate" },