From 58a7d19d8d6e316a00ccf3d8ca4ee6f022811239 Mon Sep 17 00:00:00 2001 From: Alise Au <20424172+ahiuchingau@users.noreply.github.com> Date: Fri, 9 Aug 2024 13:43:15 -0400 Subject: [PATCH] feat(protocol-engine): absorbance plate reader engine load behavior and lid movement command (#15599) Covers PLAT-447 PLAT-381 PLAT-210 PLAT-209 PLAT-215 Introduces the fundamental load module and labware presence behavior for the plate reader and it's associated lid, includes the `open_lid` and `close_lid` commands for the plate reader. --- api/setup.py | 1 + .../drivers/absorbance_reader/async_byonoy.py | 18 +- .../drivers/absorbance_reader/hid_protocol.py | 8 +- .../protocol_api/core/engine/labware.py | 12 +- .../protocol_api/core/engine/module_core.py | 25 +- .../protocol_api/core/engine/protocol.py | 25 ++ api/src/opentrons/protocol_api/core/module.py | 12 +- .../opentrons/protocol_api/module_contexts.py | 22 +- .../protocol_api/protocol_context.py | 11 +- .../protocol_engine/actions/__init__.py | 2 + .../protocol_engine/actions/actions.py | 12 + .../protocol_engine/clients/sync_client.py | 6 + .../commands/absorbance_reader/__init__.py | 53 ++++- .../commands/absorbance_reader/close_lid.py | 135 +++++++++++ .../commands/absorbance_reader/open_lid.py | 123 ++++++++++ .../absorbance_reader/{measure.py => read.py} | 42 ++-- .../commands/absorbance_reader/types.py | 25 ++ .../commands/command_unions.py | 20 +- .../protocol_engine/commands/load_labware.py | 5 +- .../protocol_engine/create_protocol_engine.py | 7 +- .../protocol_engine/errors/__init__.py | 2 + .../protocol_engine/errors/exceptions.py | 13 + .../protocol_engine/protocol_engine.py | 7 + .../resources/deck_data_provider.py | 63 ++++- .../resources/fixture_validation.py | 5 + .../resources/labware_validation.py | 5 + .../protocol_engine/state/geometry.py | 22 +- .../protocol_engine/state/labware.py | 15 ++ .../absorbance_reader_substate.py | 13 + .../protocol_engine/state/modules.py | 222 +++++++++++------- .../opentrons/protocol_engine/state/state.py | 1 + api/src/opentrons/protocol_engine/types.py | 1 + api/src/opentrons/types.py | 17 ++ .../engine/test_absorbance_reader_core.py | 6 +- .../test_absorbance_reader_context.py | 4 +- .../opentrons/protocol_engine/conftest.py | 8 + .../resources/test_deck_data_provider.py | 66 +++++- .../state/test_geometry_view.py | 4 +- .../state/test_module_store.py | 13 + .../protocol_engine/state/test_module_view.py | 1 + .../localization/en/device_details.json | 1 + .../hooks/useModuleCalibrationStatus.ts | 8 +- .../AbsorbanceReaderFixture.tsx | 5 +- shared-data/command/schemas/8.json | 132 +++++++++-- shared-data/command/schemas/9.json | 132 +++++++++-- .../deck/definitions/5/ot3_standard.json | 84 ++++++- shared-data/deck/schemas/5.json | 5 +- shared-data/deck/types/schemaV5.ts | 4 + shared-data/js/constants.ts | 12 + shared-data/js/getLabware.ts | 2 + shared-data/js/labware.ts | 3 + shared-data/js/types.ts | 1 + .../1.json | 57 +++++ .../definitions/3/absorbanceReaderV1.json | 18 +- 54 files changed, 1281 insertions(+), 235 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py create mode 100644 api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py rename api/src/opentrons/protocol_engine/commands/absorbance_reader/{measure.py => read.py} (64%) create mode 100644 api/src/opentrons/protocol_engine/commands/absorbance_reader/types.py create mode 100644 shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json diff --git a/api/setup.py b/api/setup.py index 1b2a7dde508..92f06b49bef 100755 --- a/api/setup.py +++ b/api/setup.py @@ -65,6 +65,7 @@ def get_version(): "pyserial>=3.5", "typing-extensions>=4.0.0,<5", "click>=8.0.0,<9", + "pyusb==1.2.1", 'importlib-metadata >= 1.0 ; python_version < "3.8"', "packaging>=21.0", ] diff --git a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py index d67883cd062..f21010deb0f 100644 --- a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py +++ b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py @@ -11,6 +11,7 @@ AbsorbanceHidInterface as AbsProtocol, ErrorCodeNames, DeviceStateNames, + SlotStateNames, ) from opentrons.drivers.types import ( AbsorbanceReaderLidStatus, @@ -276,7 +277,10 @@ async def get_single_measurement(self, wavelength: int) -> List[float]: ) async def get_plate_presence(self) -> AbsorbanceReaderPlatePresence: - return AbsorbanceReaderPlatePresence.UNKNOWN + presence = await self._loop.run_in_executor( + executor=self._executor, func=self._get_slot_status + ) + return self.convert_plate_presence(presence.name) async def get_device_status(self) -> AbsorbanceReaderDeviceState: status = await self._loop.run_in_executor( @@ -296,3 +300,15 @@ def convert_device_state( "BYONOY_DEVICE_STATE_ERROR": AbsorbanceReaderDeviceState.ERROR, } return state_map[device_state] + + @staticmethod + def convert_plate_presence( + slot_state: SlotStateNames, + ) -> AbsorbanceReaderPlatePresence: + state_map: Dict[SlotStateNames, AbsorbanceReaderPlatePresence] = { + "UNKNOWN": AbsorbanceReaderPlatePresence.UNKNOWN, + "EMPTY": AbsorbanceReaderPlatePresence.ABSENT, + "OCCUPIED": AbsorbanceReaderPlatePresence.PRESENT, + "UNDETERMINED": AbsorbanceReaderPlatePresence.UNKNOWN, + } + return state_map[slot_state] diff --git a/api/src/opentrons/drivers/absorbance_reader/hid_protocol.py b/api/src/opentrons/drivers/absorbance_reader/hid_protocol.py index 2efee337846..17ca2cabdcc 100644 --- a/api/src/opentrons/drivers/absorbance_reader/hid_protocol.py +++ b/api/src/opentrons/drivers/absorbance_reader/hid_protocol.py @@ -36,10 +36,10 @@ ] SlotStateNames = Literal[ - "BYONOY_SLOT_UNKNOWN", - "BYONOY_SLOT_EMPTY", - "BYONOY_SLOT_OCCUPIED", - "BYONOY_SLOT_UNDETERMINED", + "UNKNOWN", + "EMPTY", + "OCCUPIED", + "UNDETERMINED", ] DeviceStateNames = Literal[ diff --git a/api/src/opentrons/protocol_api/core/engine/labware.py b/api/src/opentrons/protocol_api/core/engine/labware.py index fc32038d5a1..f09a51ef181 100644 --- a/api/src/opentrons/protocol_api/core/engine/labware.py +++ b/api/src/opentrons/protocol_api/core/engine/labware.py @@ -9,7 +9,11 @@ from opentrons_shared_data.labware.labware_definition import LabwareRole from opentrons.protocol_engine import commands as cmd -from opentrons.protocol_engine.errors import LabwareNotOnDeckError, ModuleNotOnDeckError +from opentrons.protocol_engine.errors import ( + LabwareNotOnDeckError, + ModuleNotOnDeckError, + LocationIsStagingSlotError, +) from opentrons.protocol_engine.clients import SyncClient as ProtocolEngineClient from opentrons.protocol_engine.types import ( LabwareOffsetCreate, @@ -185,5 +189,9 @@ def get_deck_slot(self) -> Optional[DeckSlotName]: return self._engine_client.state.geometry.get_ancestor_slot_name( self.labware_id ) - except (LabwareNotOnDeckError, ModuleNotOnDeckError): + except ( + LabwareNotOnDeckError, + ModuleNotOnDeckError, + LocationIsStagingSlotError, + ): return None 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 2a9cff4b79e..4b29874fab8 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -16,8 +16,9 @@ HeaterShakerLabwareLatchStatus, ThermocyclerLidStatus, ) -from opentrons.types import DeckSlotName + from opentrons.protocol_engine import commands as cmd +from opentrons.types import DeckSlotName from opentrons.protocol_engine.clients import SyncClient as ProtocolEngineClient from opentrons.protocol_engine.errors.exceptions import ( LabwareNotLoadedOnModuleError, @@ -535,11 +536,29 @@ def initialize(self, wavelength: int) -> None: ) self._initialized_value = wavelength - def initiate_read(self) -> None: + def read(self) -> None: """Initiate read on the Absorbance Reader.""" if self._initialized_value: self._engine_client.execute_command( - cmd.absorbance_reader.MeasureAbsorbanceParams( + cmd.absorbance_reader.ReadAbsorbanceParams( moduleId=self.module_id, sampleWavelength=self._initialized_value ) ) + + def close_lid( + self, + ) -> None: + """Close the Absorbance Reader's lid.""" + self._engine_client.execute_command( + cmd.absorbance_reader.CloseLidParams( + moduleId=self.module_id, + ) + ) + + def open_lid(self) -> None: + """Close the Absorbance Reader's lid.""" + self._engine_client.execute_command( + cmd.absorbance_reader.OpenLidParams( + 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 67cb369c306..04ddaf55a48 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -441,10 +441,35 @@ def load_module( existing_module_ids=list(self._module_cores_by_id.keys()), ) + # When the protocol engine is created, we add Module Lids as part of the deck fixed labware + # If a valid module exists in the deck config. For analysis, we add the labware here since + # deck fixed labware is not created under the same conditions. + if self._engine_client.state.config.use_virtual_modules: + self._load_virtual_module_lid(module_core) + self._module_cores_by_id[module_core.module_id] = module_core return module_core + def _load_virtual_module_lid( + self, module_core: Union[ModuleCore, NonConnectedModuleCore] + ) -> None: + if isinstance(module_core, AbsorbanceReaderCore): + lid = self._engine_client.execute_command_without_recovery( + cmd.LoadLabwareParams( + loadName="opentrons_flex_lid_absorbance_plate_reader_module", + location=ModuleLocation(moduleId=module_core.module_id), + namespace="opentrons", + version=1, + displayName="Absorbance Reader Lid", + ) + ) + + self._engine_client.add_absorbance_reader_lid( + module_id=module_core.module_id, + lid_id=lid.labwareId, + ) + def _create_non_connected_module_core( self, load_module_result: LoadModuleResult ) -> NonConnectedModuleCore: diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index 004bd5c85b6..b4c7c263375 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -359,5 +359,13 @@ def initialize(self, wavelength: int) -> None: """Initialize the Absorbance Reader by taking zero reading.""" @abstractmethod - def initiate_read(self) -> None: - """Initiate read on the Absorbance Reader.""" + def read(self) -> None: + """Get an absorbance reading from the Absorbance Reader.""" + + @abstractmethod + def close_lid(self) -> None: + """Close the Absorbance Reader's lid.""" + + @abstractmethod + def open_lid(self) -> None: + """Open the Absorbance Reader's lid.""" diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index 45f53a95de1..7ec626d76b3 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -976,23 +976,33 @@ class AbsorbanceReaderContext(ModuleContext): It should not be instantiated directly; instead, it should be created through :py:meth:`.ProtocolContext.load_module`. - .. versionadded:: 2.18 + .. versionadded:: 2.21 """ _core: AbsorbanceReaderCore @property - @requires_version(2, 18) + @requires_version(2, 21) def serial_number(self) -> str: """Get the module's unique hardware serial number.""" return self._core.get_serial_number() - @requires_version(2, 18) + @requires_version(2, 21) + def close_lid(self) -> None: + """Close the lid of the Absorbance Reader.""" + self._core.close_lid() + + @requires_version(2, 21) + def open_lid(self) -> None: + """Close the lid of the Absorbance Reader.""" + self._core.open_lid() + + @requires_version(2, 21) def initialize(self, wavelength: int) -> None: """Initialize the Absorbance Reader by taking zero reading.""" self._core.initialize(wavelength) - @requires_version(2, 18) - def initiate_read(self) -> None: + @requires_version(2, 21) + def read(self) -> None: """Initiate read on the Absorbance Reader.""" - self._core.initiate_read() + self._core.read() diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 054af703fe7..57dd361d54e 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -17,7 +17,10 @@ from opentrons.types import Mount, Location, DeckLocation, DeckSlotName, StagingSlotName from opentrons.legacy_broker import LegacyBroker -from opentrons.hardware_control.modules.types import MagneticBlockModel +from opentrons.hardware_control.modules.types import ( + MagneticBlockModel, + AbsorbanceReaderModel, +) from opentrons.legacy_commands import protocol_commands as cmds, types as cmd_types from opentrons.legacy_commands.helpers import stringify_labware_movement_command from opentrons.legacy_commands.publisher import ( @@ -827,6 +830,12 @@ def load_module( until_version="2.15", current_version=f"{self._api_version}", ) + if isinstance( + 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." + ) deck_slot = ( None diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index b6c7cd7d852..ff59548971d 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -28,6 +28,7 @@ DoorChangeAction, ResetTipsAction, SetPipetteMovementSpeedAction, + AddAbsorbanceReaderLidAction, ) __all__ = [ @@ -56,6 +57,7 @@ "DoorChangeAction", "ResetTipsAction", "SetPipetteMovementSpeedAction", + "AddAbsorbanceReaderLidAction", # action payload values "PauseSource", "FinishErrorDetails", diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 142870ee40a..38cbbe18bb3 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -266,6 +266,17 @@ class SetPipetteMovementSpeedAction: speed: Optional[float] +@dataclass(frozen=True) +class AddAbsorbanceReaderLidAction: + """Add the absorbance reader lid id to the absorbance reader module substate. + + This action is dispatched the absorbance reader module is first loaded. + """ + + module_id: str + lid_id: str + + @dataclass(frozen=True) class SetErrorRecoveryPolicyAction: """See `ProtocolEngine.set_error_recovery_policy()`.""" @@ -293,5 +304,6 @@ class SetErrorRecoveryPolicyAction: AddLiquidAction, ResetTipsAction, SetPipetteMovementSpeedAction, + AddAbsorbanceReaderLidAction, SetErrorRecoveryPolicyAction, ] diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 59407e1d1fe..26356a76a15 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -119,6 +119,12 @@ def add_addressable_area(self, addressable_area_name: str) -> None: "add_addressable_area", addressable_area_name=addressable_area_name ) + def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: + """Add an absorbance reader lid to the module state.""" + self._transport.call_method( + "add_absorbance_reader_lid", module_id=module_id, lid_id=lid_id + ) + def add_liquid( self, name: str, color: Optional[str], description: Optional[str] ) -> Liquid: diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/__init__.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/__init__.py index 6f2db320b4f..09307e85230 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/__init__.py @@ -1,4 +1,20 @@ """Command models for Absorbance Reader commands.""" +from .types import MoveLidResult +from .close_lid import ( + CloseLidCommandType, + CloseLidParams, + CloseLidResult, + CloseLid, + CloseLidCreate, +) + +from .open_lid import ( + OpenLidCommandType, + OpenLidParams, + OpenLidResult, + OpenLid, + OpenLidCreate, +) from .initialize import ( InitializeCommandType, @@ -8,15 +24,29 @@ InitializeCreate, ) -from .measure import ( - MeasureAbsorbanceCommandType, - MeasureAbsorbanceParams, - MeasureAbsorbanceResult, - MeasureAbsorbance, - MeasureAbsorbanceCreate, +from .read import ( + ReadAbsorbanceCommandType, + ReadAbsorbanceParams, + ReadAbsorbanceResult, + ReadAbsorbance, + ReadAbsorbanceCreate, ) + __all__ = [ + "MoveLidResult", + # absorbanace_reader/closeLid + "CloseLidCommandType", + "CloseLidParams", + "CloseLidResult", + "CloseLid", + "CloseLidCreate", + # absorbanace_reader/openLid + "OpenLidCommandType", + "OpenLidParams", + "OpenLidResult", + "OpenLid", + "OpenLidCreate", # absorbanace_reader/initialize "InitializeCommandType", "InitializeParams", @@ -24,9 +54,10 @@ "Initialize", "InitializeCreate", # absorbanace_reader/measure - "MeasureAbsorbanceCommandType", - "MeasureAbsorbanceParams", - "MeasureAbsorbanceResult", - "MeasureAbsorbance", - "MeasureAbsorbanceCreate", + "ReadAbsorbanceCommandType", + "ReadAbsorbanceParams", + "ReadAbsorbanceResult", + "ReadAbsorbance", + "ReadAbsorbanceCreate", + # union type ] diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py new file mode 100644 index 00000000000..e7b7702a814 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -0,0 +1,135 @@ +"""Command models to close the lid on an Absorbance Reader.""" +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.error_occurrence import ErrorOccurrence +from opentrons.protocol_engine.types import AddressableAreaLocation +from opentrons.protocol_engine.resources import labware_validation +from .types import MoveLidResult + +if TYPE_CHECKING: + from opentrons.protocol_engine.state import StateView + from opentrons.protocol_engine.execution import ( + EquipmentHandler, + LabwareMovementHandler, + ) + + +CloseLidCommandType = Literal["absorbanceReader/closeLid"] + + +class CloseLidParams(BaseModel): + """Input parameters to close the lid on an absorbance reading.""" + + moduleId: str = Field(..., description="Unique ID of the absorbance reader.") + + +class CloseLidResult(MoveLidResult): + """Result data from closing the lid on an aborbance reading.""" + + +class CloseLidImpl( + AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult, None]] +): + """Execution implementation of closing the lid on an Absorbance Reader.""" + + def __init__( + self, + state_view: StateView, + equipment: EquipmentHandler, + labware_movement: LabwareMovementHandler, + **unused_dependencies: object, + ) -> None: + self._state_view = state_view + self._equipment = equipment + self._labware_movement = labware_movement + + async def execute( + self, params: CloseLidParams + ) -> SuccessData[CloseLidResult, None]: + """Execute the close lid command.""" + mod_substate = self._state_view.modules.get_absorbance_reader_substate( + module_id=params.moduleId + ) + # Make sure the lid is open + mod_substate.raise_if_lid_status_not_expected(lid_on_expected=False) + + # Allow propagation of ModuleNotAttachedError. + _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + + # lid should currently be docked + assert mod_substate.lid_id is not None + loaded_lid = self._state_view.labware.get(mod_substate.lid_id) + assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) + + current_location = loaded_lid.location + validated_current_location = ( + self._state_view.geometry.ensure_valid_gripper_location(current_location) + ) + + # we need to move the lid onto the module reader + absorbance_model = self._state_view.modules.get_requested_model(params.moduleId) + assert absorbance_model is not None + new_location = AddressableAreaLocation( + addressableAreaName=self._state_view.modules.ensure_and_convert_module_fixture_location( + deck_slot=self._state_view.modules.get_location( + params.moduleId + ).slotName, + deck_type=self._state_view.config.deck_type, + model=absorbance_model, + ) + ) + validated_new_location = ( + self._state_view.geometry.ensure_valid_gripper_location(new_location) + ) + + lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( + loaded_lid.id, None + ) + if lid_gripper_offsets is None: + raise ValueError( + "Gripper Offset values for Absorbance Reader Lid labware must not be None." + ) + + # Skips gripper moves when using virtual gripper + await self._labware_movement.move_labware_with_gripper( + labware_id=loaded_lid.id, + current_location=validated_current_location, + new_location=validated_new_location, + user_offset_data=lid_gripper_offsets, + post_drop_slide_offset=None, + ) + + new_offset_id = self._equipment.find_applicable_labware_offset_id( + labware_definition_uri=loaded_lid.definitionUri, + labware_location=new_location, + ) + return SuccessData( + public=CloseLidResult( + lidId=loaded_lid.id, newLocation=new_location, offsetId=new_offset_id + ), + private=None, + ) + + +class CloseLid(BaseCommand[CloseLidParams, CloseLidResult, ErrorOccurrence]): + """A command to close the lid on an Absorbance Reader.""" + + commandType: CloseLidCommandType = "absorbanceReader/closeLid" + params: CloseLidParams + result: Optional[CloseLidResult] + + _ImplementationCls: Type[CloseLidImpl] = CloseLidImpl + + +class CloseLidCreate(BaseCommandCreate[CloseLidParams]): + """A request to execute an Absorbance Reader close lid command.""" + + commandType: CloseLidCommandType = "absorbanceReader/closeLid" + params: CloseLidParams + + _CommandCls: Type[CloseLid] = CloseLid diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py new file mode 100644 index 00000000000..2190813d6a4 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -0,0 +1,123 @@ +"""Command models to close the lid on an Absorbance Reader.""" +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.error_occurrence import ErrorOccurrence +from .types import MoveLidResult +from opentrons.protocol_engine.resources import labware_validation + +if TYPE_CHECKING: + from opentrons.protocol_engine.state import StateView + from opentrons.protocol_engine.execution import ( + EquipmentHandler, + LabwareMovementHandler, + ) + + +OpenLidCommandType = Literal["absorbanceReader/openLid"] + + +class OpenLidParams(BaseModel): + """Input parameters to open the lid on an absorbance reading.""" + + moduleId: str = Field(..., description="Unique ID of the absorbance reader.") + + +class OpenLidResult(MoveLidResult): + """Result data from opening the lid on an aborbance reading.""" + + +class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult, None]]): + """Execution implementation of opening the lid on an Absorbance Reader.""" + + def __init__( + self, + state_view: StateView, + equipment: EquipmentHandler, + labware_movement: LabwareMovementHandler, + **unused_dependencies: object, + ) -> None: + self._state_view = state_view + self._equipment = equipment + self._labware_movement = labware_movement + + async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, None]: + """Move the absorbance reader lid from the module to the lid dock.""" + mod_substate = self._state_view.modules.get_absorbance_reader_substate( + module_id=params.moduleId + ) + # Make sure the lid is closed + mod_substate.raise_if_lid_status_not_expected(lid_on_expected=True) + + # Allow propagation of ModuleNotAttachedError. + _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + + # lid should currently be on the module + assert mod_substate.lid_id is not None + loaded_lid = self._state_view.labware.get(mod_substate.lid_id) + assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) + + current_location = loaded_lid.location + validated_current_location = ( + self._state_view.geometry.ensure_valid_gripper_location(current_location) + ) + + # we need to move the lid to the lid dock + new_location = self._state_view.modules.absorbance_reader_dock_location( + mod_substate.module_id + ) + validated_new_location = ( + self._state_view.geometry.ensure_valid_gripper_location(new_location) + ) + + lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( + loaded_lid.id, None + ) + if lid_gripper_offsets is None: + raise ValueError( + "Gripper Offset values for Absorbance Reader Lid labware must not be None." + ) + + # Skips gripper moves when using virtual gripper + await self._labware_movement.move_labware_with_gripper( + labware_id=loaded_lid.id, + current_location=validated_current_location, + new_location=validated_new_location, + user_offset_data=lid_gripper_offsets, + post_drop_slide_offset=None, + ) + new_offset_id = self._equipment.find_applicable_labware_offset_id( + labware_definition_uri=loaded_lid.definitionUri, + labware_location=new_location, + ) + return SuccessData( + public=OpenLidResult( + lidId=loaded_lid.id, + newLocation=validated_new_location, + offsetId=new_offset_id, + ), + private=None, + ) + + +class OpenLid(BaseCommand[OpenLidParams, OpenLidResult, ErrorOccurrence]): + """A command to open the lid on an Absorbance Reader.""" + + commandType: OpenLidCommandType = "absorbanceReader/openLid" + params: OpenLidParams + result: Optional[OpenLidResult] + + _ImplementationCls: Type[OpenLidImpl] = OpenLidImpl + + +class OpenLidCreate(BaseCommandCreate[OpenLidParams]): + """A request to execute an Absorbance Reader open lid command.""" + + commandType: OpenLidCommandType = "absorbanceReader/openLid" + params: OpenLidParams + + _CommandCls: Type[OpenLid] = OpenLid diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/measure.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py similarity index 64% rename from api/src/opentrons/protocol_engine/commands/absorbance_reader/measure.py rename to api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py index 53b87bd2649..327613dcfb2 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/measure.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py @@ -1,4 +1,4 @@ -"""Command models to measure absorbance.""" +"""Command models to read absorbance.""" from __future__ import annotations from typing import List, Optional, TYPE_CHECKING from typing_extensions import Literal, Type @@ -13,17 +13,17 @@ from opentrons.protocol_engine.execution import EquipmentHandler -MeasureAbsorbanceCommandType = Literal["absorbanceReader/measure"] +ReadAbsorbanceCommandType = Literal["absorbanceReader/read"] -class MeasureAbsorbanceParams(BaseModel): +class ReadAbsorbanceParams(BaseModel): """Input parameters for a single absorbance reading.""" moduleId: str = Field(..., description="Unique ID of the Absorbance Reader.") sampleWavelength: int = Field(..., description="Sample wavelength in nm.") -class MeasureAbsorbanceResult(BaseModel): +class ReadAbsorbanceResult(BaseModel): """Result data from running an aborbance reading.""" # TODO: Transform this into a more complex model, such as a map with well names. @@ -32,10 +32,8 @@ class MeasureAbsorbanceResult(BaseModel): ) -class MeasureAbsorbanceImpl( - AbstractCommandImpl[ - MeasureAbsorbanceParams, SuccessData[MeasureAbsorbanceResult, None] - ] +class ReadAbsorbanceImpl( + AbstractCommandImpl[ReadAbsorbanceParams, SuccessData[ReadAbsorbanceResult, None]] ): """Execution implementation of an Absorbance Reader measurement.""" @@ -49,8 +47,8 @@ def __init__( self._equipment = equipment async def execute( - self, params: MeasureAbsorbanceParams - ) -> SuccessData[MeasureAbsorbanceResult, None]: + self, params: ReadAbsorbanceParams + ) -> SuccessData[ReadAbsorbanceResult, None]: """Initiate a single absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -63,32 +61,32 @@ async def execute( if abs_reader is not None: result = await abs_reader.start_measure(wavelength=params.sampleWavelength) return SuccessData( - public=MeasureAbsorbanceResult(data=result), + public=ReadAbsorbanceResult(data=result), private=None, ) return SuccessData( - public=MeasureAbsorbanceResult(data=None), + public=ReadAbsorbanceResult(data=None), private=None, ) -class MeasureAbsorbance( - BaseCommand[MeasureAbsorbanceParams, MeasureAbsorbanceResult, ErrorOccurrence] +class ReadAbsorbance( + BaseCommand[ReadAbsorbanceParams, ReadAbsorbanceResult, ErrorOccurrence] ): """A command to execute an Absorbance Reader measurement.""" - commandType: MeasureAbsorbanceCommandType = "absorbanceReader/measure" - params: MeasureAbsorbanceParams - result: Optional[MeasureAbsorbanceResult] + commandType: ReadAbsorbanceCommandType = "absorbanceReader/read" + params: ReadAbsorbanceParams + result: Optional[ReadAbsorbanceResult] - _ImplementationCls: Type[MeasureAbsorbanceImpl] = MeasureAbsorbanceImpl + _ImplementationCls: Type[ReadAbsorbanceImpl] = ReadAbsorbanceImpl -class MeasureAbsorbanceCreate(BaseCommandCreate[MeasureAbsorbanceParams]): +class ReadAbsorbanceCreate(BaseCommandCreate[ReadAbsorbanceParams]): """A request to execute an Absorbance Reader measurement.""" - commandType: MeasureAbsorbanceCommandType = "absorbanceReader/measure" - params: MeasureAbsorbanceParams + commandType: ReadAbsorbanceCommandType = "absorbanceReader/read" + params: ReadAbsorbanceParams - _CommandCls: Type[MeasureAbsorbance] = MeasureAbsorbance + _CommandCls: Type[ReadAbsorbance] = ReadAbsorbance diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/types.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/types.py new file mode 100644 index 00000000000..5595502d6a1 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/types.py @@ -0,0 +1,25 @@ +"""Move Lid Result model.""" + +from typing import Optional +from pydantic import BaseModel, Field +from opentrons.protocol_engine.types import LabwareLocation + + +class MoveLidResult(BaseModel): + """Input parameters to open the lid on an absorbance reading.""" + + lidId: str = Field(..., description="Unique ID of the absorbance reader lid.") + newLocation: LabwareLocation = Field(..., description="New location of the lid") + offsetId: Optional[str] = Field( + # Default `None` instead of `...` so this field shows up as non-required in + # OpenAPI. The server is allowed to omit it or make it null. + None, + description=( + "An ID referencing the labware offset that will apply to this labware" + " now that it's in the new location." + " This offset will be in effect until the labware is moved" + " with another `moveLabware` command." + " Null or undefined means no offset applies," + " so the default of (0, 0, 0) will be used." + ), + ) diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index eeafb1770b6..bd45a48e7d8 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -382,8 +382,10 @@ thermocycler.OpenLid, thermocycler.CloseLid, thermocycler.RunProfile, + absorbance_reader.CloseLid, + absorbance_reader.OpenLid, absorbance_reader.Initialize, - absorbance_reader.MeasureAbsorbance, + absorbance_reader.ReadAbsorbance, calibration.CalibrateGripper, calibration.CalibratePipette, calibration.CalibrateModule, @@ -454,8 +456,10 @@ thermocycler.OpenLidParams, thermocycler.CloseLidParams, thermocycler.RunProfileParams, + absorbance_reader.CloseLidParams, + absorbance_reader.OpenLidParams, absorbance_reader.InitializeParams, - absorbance_reader.MeasureAbsorbanceParams, + absorbance_reader.ReadAbsorbanceParams, calibration.CalibrateGripperParams, calibration.CalibratePipetteParams, calibration.CalibrateModuleParams, @@ -524,8 +528,10 @@ thermocycler.OpenLidCommandType, thermocycler.CloseLidCommandType, thermocycler.RunProfileCommandType, + absorbance_reader.CloseLidCommandType, + absorbance_reader.OpenLidCommandType, absorbance_reader.InitializeCommandType, - absorbance_reader.MeasureAbsorbanceCommandType, + absorbance_reader.ReadAbsorbanceCommandType, calibration.CalibrateGripperCommandType, calibration.CalibratePipetteCommandType, calibration.CalibrateModuleCommandType, @@ -595,8 +601,10 @@ thermocycler.OpenLidCreate, thermocycler.CloseLidCreate, thermocycler.RunProfileCreate, + absorbance_reader.CloseLidCreate, + absorbance_reader.OpenLidCreate, absorbance_reader.InitializeCreate, - absorbance_reader.MeasureAbsorbanceCreate, + absorbance_reader.ReadAbsorbanceCreate, calibration.CalibrateGripperCreate, calibration.CalibratePipetteCreate, calibration.CalibrateModuleCreate, @@ -667,8 +675,10 @@ thermocycler.OpenLidResult, thermocycler.CloseLidResult, thermocycler.RunProfileResult, + absorbance_reader.CloseLidResult, + absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, - absorbance_reader.MeasureAbsorbanceResult, + absorbance_reader.ReadAbsorbanceResult, calibration.CalibrateGripperResult, calibration.CalibratePipetteResult, calibration.CalibrateModuleResult, diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 6e37607984c..6a4b53f4180 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -115,7 +115,10 @@ async def execute( if isinstance(params.location, AddressableAreaLocation): area_name = params.location.addressableAreaName - if not fixture_validation.is_deck_slot(params.location.addressableAreaName): + if not ( + fixture_validation.is_deck_slot(params.location.addressableAreaName) + or fixture_validation.is_abs_reader(params.location.addressableAreaName) + ): raise LabwareIsNotAllowedInLocationError( f"Cannot load {params.loadName} onto addressable area {area_name}" ) diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index 59f004cc7cd..5986130bded 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -40,11 +40,10 @@ async def create_protocol_engine( """ deck_data = DeckDataProvider(config.deck_type) deck_definition = await deck_data.get_deck_definition() - deck_fixed_labware = ( - await deck_data.get_deck_fixed_labware(deck_definition) - if load_fixed_trash - else [] + deck_fixed_labware = await deck_data.get_deck_fixed_labware( + load_fixed_trash, deck_definition, deck_configuration ) + module_calibration_offsets = ModuleDataProvider.load_module_calibrations() robot_definition = load_robot(config.robot_type) state_store = StateStore( diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 994e4cc9ed3..639648e820f 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -65,6 +65,7 @@ LocationIsOccupiedError, LocationNotAccessibleByPipetteError, LocationIsStagingSlotError, + LocationIsLidDockSlotError, InvalidAxisForRobotType, NotSupportedOnRobotType, CommandNotAllowedError, @@ -139,6 +140,7 @@ "LocationIsOccupiedError", "LocationNotAccessibleByPipetteError", "LocationIsStagingSlotError", + "LocationIsLidDockSlotError", "InvalidAxisForRobotType", "NotSupportedOnRobotType", # error occurrence models diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 253c037598a..8d8ed34fb9f 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -897,6 +897,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class LocationIsLidDockSlotError(ProtocolEngineError): + """Raised when referencing a labware on a lid dock slot when trying to get standard deck slot.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LocationIsLidDockSlotError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class FirmwareUpdateRequired(ProtocolEngineError): """Raised when the firmware needs to be updated.""" diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 2c0b4cc1925..ffb251166cd 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -58,6 +58,7 @@ HardwareStoppedAction, ResetTipsAction, SetPipetteMovementSpeedAction, + AddAbsorbanceReaderLidAction, ) @@ -561,6 +562,12 @@ def add_addressable_area(self, addressable_area_name: str) -> None: AddAddressableAreaAction(addressable_area=area) ) + def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: + """Add an absorbance reader lid to the module state.""" + self._action_dispatcher.dispatch( + AddAbsorbanceReaderLidAction(module_id=module_id, lid_id=lid_id) + ) + def reset_tips(self, labware_id: str) -> None: """Reset the tip state of a given labware.""" # TODO(mm, 2023-03-10): Safely raise an error if the given labware isn't a diff --git a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py index c373ce766db..c67260a8001 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py @@ -13,8 +13,15 @@ from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName -from ..types import DeckSlotLocation, DeckType +from ..types import ( + DeckSlotLocation, + DeckType, + LabwareLocation, + AddressableAreaLocation, + DeckConfigurationType, +) from .labware_data_provider import LabwareDataProvider +from ..resources import deck_configuration_provider @final @@ -23,7 +30,7 @@ class DeckFixedLabware: """A labware fixture that is always present on a deck.""" labware_id: str - location: DeckSlotLocation + location: LabwareLocation definition: LabwareDefinition @@ -51,7 +58,9 @@ def sync() -> DeckDefinitionV5: async def get_deck_fixed_labware( self, + load_fixed_trash: bool, deck_definition: DeckDefinitionV5, + deck_configuration: Optional[DeckConfigurationType] = None, ) -> List[DeckFixedLabware]: """Get a list of all labware fixtures from a given deck definition.""" labware: List[DeckFixedLabware] = [] @@ -61,8 +70,52 @@ async def get_deck_fixed_labware( load_name = cast(Optional[str], fixture.get("labware")) slot = cast(Optional[str], fixture.get("slot")) - if load_name is not None and slot is not None: - location = DeckSlotLocation(slotName=DeckSlotName.from_primitive(slot)) + if ( + deck_configuration is not None + and load_name is not None + and slot is not None + and slot not in DeckSlotName._value2member_map_ + ): + # The provided slot is likely to be an addressable area for Module-required labware Eg: Plate Reader Lid + for ( + cutout_id, + cutout_fixture_id, + opentrons_module_serial_number, + ) in deck_configuration: + provided_addressable_areas = ( + deck_configuration_provider.get_provided_addressable_area_names( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + deck_definition=deck_definition, + ) + ) + if slot in provided_addressable_areas: + addressable_area_location = AddressableAreaLocation( + addressableAreaName=slot + ) + definition = await self._labware_data.get_labware_definition( + load_name=load_name, + namespace="opentrons", + version=1, + ) + + labware.append( + DeckFixedLabware( + labware_id=labware_id, + definition=definition, + location=addressable_area_location, + ) + ) + + elif ( + load_fixed_trash + and load_name is not None + and slot is not None + and slot in DeckSlotName._value2member_map_ + ): + deck_slot_location = DeckSlotLocation( + slotName=DeckSlotName.from_primitive(slot) + ) definition = await self._labware_data.get_labware_definition( load_name=load_name, namespace="opentrons", @@ -73,7 +126,7 @@ async def get_deck_fixed_labware( DeckFixedLabware( labware_id=labware_id, definition=definition, - location=location, + location=deck_slot_location, ) ) diff --git a/api/src/opentrons/protocol_engine/resources/fixture_validation.py b/api/src/opentrons/protocol_engine/resources/fixture_validation.py index 9807cc6beaa..745df22d712 100644 --- a/api/src/opentrons/protocol_engine/resources/fixture_validation.py +++ b/api/src/opentrons/protocol_engine/resources/fixture_validation.py @@ -46,3 +46,8 @@ def is_deck_slot(addressable_area_name: str) -> bool: except ValueError: return False return True + + +def is_abs_reader(addressable_area_name: str) -> bool: + """Check if an addressable area is an absorbance plate reader area.""" + return "absorbanceReaderV1" in addressable_area_name diff --git a/api/src/opentrons/protocol_engine/resources/labware_validation.py b/api/src/opentrons/protocol_engine/resources/labware_validation.py index 75cb46e4cdd..3b4ed14166c 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_validation.py +++ b/api/src/opentrons/protocol_engine/resources/labware_validation.py @@ -9,6 +9,11 @@ def is_flex_trash(load_name: str) -> bool: return load_name == "opentrons_1_trash_3200ml_fixed" +def is_absorbance_reader_lid(load_name: str) -> bool: + """Check if a labware is an absorbance reader lid.""" + return load_name == "opentrons_flex_lid_absorbance_plate_reader_module" + + def validate_definition_is_labware(definition: LabwareDefinition) -> bool: """Validate that one of the definition's allowed roles is `labware`. diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 7b02e1242da..9be6f7e5952 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -241,11 +241,16 @@ def get_min_travel_z( def get_labware_parent_nominal_position(self, labware_id: str) -> Point: """Get the position of the labware's uncalibrated parent slot (deck, module, or another labware).""" try: - slot_name = self.get_ancestor_slot_name(labware_id).id + addressable_area_name = self.get_ancestor_slot_name(labware_id).id except errors.LocationIsStagingSlotError: - slot_name = self._get_staging_slot_name(labware_id) - slot_pos = self._addressable_areas.get_addressable_area_position(slot_name) + addressable_area_name = self._get_staging_slot_name(labware_id) + except errors.LocationIsLidDockSlotError: + addressable_area_name = self._get_lid_dock_slot_name(labware_id) + slot_pos = self._addressable_areas.get_addressable_area_position( + addressable_area_name + ) labware_data = self._labware.get(labware_id) + offset = self._get_labware_position_offset(labware_id, labware_data.location) return Point( @@ -585,6 +590,12 @@ def _get_staging_slot_name(self, labware_id: str) -> str: "Cannot get staging slot name for labware not on staging slot." ) + def _get_lid_dock_slot_name(self, labware_id: str) -> str: + """Get the staging slot name that the labware is on.""" + labware_location = self._labware.get(labware_id).location + assert isinstance(labware_location, AddressableAreaLocation) + return labware_location.addressableAreaName + def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: """Get the slot name of the labware or the module that the labware is on.""" labware = self._labware.get(labware_id) @@ -602,10 +613,15 @@ def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: area_name = labware.location.addressableAreaName # TODO we might want to eventually return some sort of staging slot name when we're ready to work through # the linting nightmare it will create + if self._labware.is_absorbance_reader_lid(labware_id): + raise errors.LocationIsLidDockSlotError( + "Cannot get ancestor slot name for labware on lid dock slot." + ) if fixture_validation.is_staging_slot(area_name): raise errors.LocationIsStagingSlotError( "Cannot get ancestor slot name for labware on staging slot." ) + raise errors.LocationIs slot_name = DeckSlotName.from_primitive(area_name) elif labware.location == OFF_DECK_LOCATION: raise errors.LabwareNotOnDeckError( diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 96eb1dac23b..10ccf053d24 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -29,6 +29,7 @@ from ..resources import DeckFixedLabware, labware_validation, fixture_validation from ..commands import ( Command, + absorbance_reader, LoadLabwareResult, MoveLabwareResult, ReloadLabwareResult, @@ -224,6 +225,14 @@ def _handle_command(self, command: Command) -> None: new_location = OFF_DECK_LOCATION self._state.labware_by_id[labware_id].location = new_location + elif isinstance(command.result, absorbance_reader.MoveLidResult): + lid_id = command.result.lidId + new_location = command.result.newLocation + new_offset_id = command.result.offsetId + + self._state.labware_by_id[lid_id].offsetId = new_offset_id + self._state.labware_by_id[lid_id].location = new_location + def _add_labware_offset(self, labware_offset: LabwareOffset) -> None: """Add a new labware offset to state. @@ -704,6 +713,12 @@ def is_fixed_trash(self, labware_id: str) -> bool: """Check if labware is fixed trash.""" return self.get_has_quirk(labware_id, "fixedTrash") + def is_absorbance_reader_lid(self, labware_id: str) -> bool: + """Check if labware is an absorbance reader lid.""" + return labware_validation.is_absorbance_reader_lid( + self.get(labware_id).loadName + ) + def raise_if_labware_inaccessible_by_pipette(self, labware_id: str) -> None: """Raise an error if the specified location cannot be reached via a pipette.""" labware = self.get(labware_id) diff --git a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py index 03daa32e147..255a3c752ac 100644 --- a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py +++ b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py @@ -2,8 +2,10 @@ from dataclasses import dataclass from typing import NewType, Optional, List +from opentrons.protocol_engine.errors import CannotPerformModuleAction AbsorbanceReaderId = NewType("AbsorbanceReaderId", str) +AbsorbanceReaderLidId = NewType("AbsorbanceReaderLidId", str) @dataclass(frozen=True) @@ -13,5 +15,16 @@ class AbsorbanceReaderSubState: module_id: AbsorbanceReaderId configured: bool measured: bool + is_lid_on: bool data: Optional[List[float]] configured_wavelength: Optional[int] + lid_id: Optional[str] + + def raise_if_lid_status_not_expected(self, lid_on_expected: bool) -> None: + """Raise if the lid status is not correct.""" + match = self.is_lid_on is lid_on_expected + if not match: + raise CannotPerformModuleAction( + "Cannot perform lid action because the lid is already " + f"{'closed' if self.is_lid_on else 'open'}" + ) diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 49794ce329d..137c5082eea 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -24,6 +24,7 @@ from opentrons.motion_planning.adjacent_slots_getters import ( get_east_slot, get_west_slot, + get_adjacent_staging_slot, ) from opentrons.protocol_engine.commands.calibration.calibrate_module import ( CalibrateModuleResult, @@ -45,7 +46,10 @@ HeaterShakerMovementRestrictors, DeckType, LabwareMovementOffsetData, + AddressableAreaLocation, ) + +from ..resources import DeckFixedLabware from .addressable_areas import AddressableAreaView from .. import errors from ..commands import ( @@ -56,7 +60,12 @@ thermocycler, absorbance_reader, ) -from ..actions import Action, SucceedCommandAction, AddModuleAction +from ..actions import ( + Action, + SucceedCommandAction, + AddModuleAction, + AddAbsorbanceReaderLidAction, +) from .abstract_store import HasState, HandlesActions from .module_substates import ( MagneticModuleSubState, @@ -174,6 +183,15 @@ class ModuleState: deck_type: DeckType """Type of deck that the modules are on.""" + deck_fixed_labware: Sequence[DeckFixedLabware] + """Fixed labware from the deck which may be assigned to a module. + + The Opentrons Plate Reader module makes use of an electronic Lid labware which moves + between the Reader and Dock positions, and is pre-loaded into the engine as to persist + even when not in use. For this reason, we inject it here when an appropriate match + is identified. + """ + class ModuleStore(HasState[ModuleState], HandlesActions): """Module state container.""" @@ -183,6 +201,7 @@ class ModuleStore(HasState[ModuleState], HandlesActions): def __init__( self, config: Config, + deck_fixed_labware: Sequence[DeckFixedLabware], module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None, ) -> None: """Initialize a ModuleStore and its state.""" @@ -194,6 +213,7 @@ def __init__( substate_by_module_id={}, module_offset_by_serial=module_calibration_offsets or {}, deck_type=config.deck_type, + deck_fixed_labware=deck_fixed_labware, ) self._robot_type = config.robot_type @@ -211,6 +231,11 @@ def handle_action(self, action: Action) -> None: requested_model=None, module_live_data=action.module_live_data, ) + elif isinstance(action, AddAbsorbanceReaderLidAction): + self._update_absorbance_reader_lid_id( + module_id=action.module_id, + lid_id=action.lid_id, + ) def _handle_command(self, command: Command) -> None: if isinstance(command.result, LoadModuleResult): @@ -269,13 +294,36 @@ def _handle_command(self, command: Command) -> None: if isinstance( command.result, ( + absorbance_reader.CloseLidResult, + absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, - absorbance_reader.MeasureAbsorbanceResult, + absorbance_reader.ReadAbsorbanceResult, ), ): self._handle_absorbance_reader_commands(command) - def _add_module_substate( + def _update_absorbance_reader_lid_id( + self, + module_id: str, + lid_id: str, + ) -> None: + abs_substate = self._state.substate_by_module_id.get(module_id) + assert isinstance( + abs_substate, AbsorbanceReaderSubState + ), f"{module_id} is not an absorbance plate reader." + + prev_state: AbsorbanceReaderSubState = abs_substate + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=prev_state.configured, + measured=prev_state.measured, + is_lid_on=prev_state.is_lid_on, + data=prev_state.data, + configured_wavelength=prev_state.configured_wavelength, + lid_id=lid_id, + ) + + def _add_module_substate( # noqa: C901 self, module_id: str, serial_number: Optional[str], @@ -334,13 +382,29 @@ def _add_module_substate( module_id=MagneticBlockId(module_id) ) elif ModuleModel.is_absorbance_reader(actual_model): - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=False, - measured=False, - data=None, - configured_wavelength=None, - ) + slot = self._state.slot_by_module_id[module_id] + if slot is not None: + reader_addressable_area = f"absorbanceReaderV1{slot.value}" + lid_labware_id = None + for labware in self._state.deck_fixed_labware: + if labware.location == AddressableAreaLocation( + addressableAreaName=reader_addressable_area + ): + lid_labware_id = labware.labware_id + break + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=False, + measured=False, + is_lid_on=True, + data=None, + configured_wavelength=None, + lid_id=lid_labware_id, + ) + else: + raise errors.ModuleNotOnDeckError( + "Opentrons Plate Reader location did not return a valid Deck Slot." + ) def _update_additional_slots_occupied_by_thermocycler( self, @@ -533,7 +597,9 @@ def _handle_absorbance_reader_commands( self, command: Union[ absorbance_reader.Initialize, - absorbance_reader.MeasureAbsorbance, + absorbance_reader.ReadAbsorbance, + absorbance_reader.CloseLid, + absorbance_reader.OpenLid, ], ) -> None: module_id = command.params.moduleId @@ -545,22 +611,50 @@ def _handle_absorbance_reader_commands( # Get current values configured = absorbance_reader_substate.configured configured_wavelength = absorbance_reader_substate.configured_wavelength + is_lid_on = absorbance_reader_substate.is_lid_on + lid_id = absorbance_reader_substate.lid_id if isinstance(command.result, absorbance_reader.InitializeResult): self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(module_id), configured=True, measured=False, + is_lid_on=is_lid_on, data=None, configured_wavelength=command.params.sampleWavelength, + lid_id=lid_id, ) - elif isinstance(command.result, absorbance_reader.MeasureAbsorbanceResult): + elif isinstance(command.result, absorbance_reader.ReadAbsorbanceResult): self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(module_id), configured=configured, configured_wavelength=configured_wavelength, + is_lid_on=is_lid_on, measured=True, data=command.result.data, + lid_id=lid_id, + ) + + elif isinstance(command.result, absorbance_reader.OpenLidResult): + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=configured, + configured_wavelength=configured_wavelength, + is_lid_on=False, + measured=True, + data=None, + lid_id=lid_id, + ) + + elif isinstance(command.result, absorbance_reader.CloseLidResult): + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=configured, + configured_wavelength=configured_wavelength, + is_lid_on=True, + measured=True, + data=None, + lid_id=lid_id, ) @@ -711,7 +805,7 @@ def get_absorbance_reader_substate( return self._get_module_substate( module_id=module_id, expected_type=AbsorbanceReaderSubState, - expected_name="Thermocycler Module", + expected_name="Absorbance Reader", ) def get_location(self, module_id: str) -> DeckSlotLocation: @@ -1194,84 +1288,40 @@ def ensure_and_convert_module_fixture_location( f"Invalid Deck Type: {deck_type.name} - Does not support modules as fixtures." ) + assert deck_slot in DeckSlotName.ot3_slots() if model == ModuleModel.MAGNETIC_BLOCK_V1: - valid_slots = [ - slot - for slot in [ - "A1", - "B1", - "C1", - "D1", - "A2", - "B2", - "C2", - "D2", - "A3", - "B3", - "C3", - "D3", - ] - ] - addressable_areas = [ - "magneticBlockV1A1", - "magneticBlockV1B1", - "magneticBlockV1C1", - "magneticBlockV1D1", - "magneticBlockV1A2", - "magneticBlockV1B2", - "magneticBlockV1C2", - "magneticBlockV1D2", - "magneticBlockV1A3", - "magneticBlockV1B3", - "magneticBlockV1C3", - "magneticBlockV1D3", - ] + return f"magneticBlockV1{deck_slot.value}" elif model == ModuleModel.HEATER_SHAKER_MODULE_V1: - valid_slots = [ - slot for slot in ["A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"] - ] - addressable_areas = [ - "heaterShakerV1A1", - "heaterShakerV1B1", - "heaterShakerV1C1", - "heaterShakerV1D1", - "heaterShakerV1A3", - "heaterShakerV1B3", - "heaterShakerV1C3", - "heaterShakerV1D3", - ] + # only allowed in column 1 & 3 + assert deck_slot.value[-1] in ("1", "3") + return f"heaterShakerV1{deck_slot.value}" + elif model == ModuleModel.TEMPERATURE_MODULE_V2: - valid_slots = [ - slot for slot in ["A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"] - ] - addressable_areas = [ - "temperatureModuleV2A1", - "temperatureModuleV2B1", - "temperatureModuleV2C1", - "temperatureModuleV2D1", - "temperatureModuleV2A3", - "temperatureModuleV2B3", - "temperatureModuleV2C3", - "temperatureModuleV2D3", - ] + # only allowed in column 1 & 3 + assert deck_slot.value[-1] in ("1", "3") + return f"temperatureModuleV2{deck_slot.value}" + elif model == ModuleModel.THERMOCYCLER_MODULE_V2: return "thermocyclerModuleV2" + elif model == ModuleModel.ABSORBANCE_READER_V1: - valid_slots = ["A3", "B3", "C3", "D3"] - addressable_areas = [ - "absorbanceReaderV1A3", - "absorbanceReaderV1B3", - "absorbanceReaderV1C3", - "absorbanceReaderV1D3", - ] - else: - raise ValueError( - f"Unknown module {model.name} has no addressable areas to provide." - ) + # only allowed in column 3 + assert deck_slot.value[-1] == "3" + return f"absorbanceReaderV1{deck_slot.value}" - map_addressable_area = { - slot: addressable_area - for slot, addressable_area in zip(valid_slots, addressable_areas) - } - return map_addressable_area[deck_slot.value] + raise ValueError( + f"Unknown module {model.name} has no addressable areas to provide." + ) + + def absorbance_reader_dock_location( + self, module_id: str + ) -> AddressableAreaLocation: + """Get the addressable area for the absorbance reader dock.""" + reader_slot = self.get_location(module_id) + lid_doc_slot = get_adjacent_staging_slot(reader_slot.slotName) + assert lid_doc_slot is not None + lid_dock_area = AddressableAreaLocation( + addressableAreaName="absorbanceReaderV1LidDock" + lid_doc_slot.value + ) + return lid_dock_area diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index a05d529f50a..4244931efd1 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -191,6 +191,7 @@ def __init__( ) self._module_store = ModuleStore( config=config, + deck_fixed_labware=deck_fixed_labware, module_calibration_offsets=module_calibration_offsets, ) self._liquid_store = LiquidStore() diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 6c19324870a..b5a81f4b015 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -797,6 +797,7 @@ class AreaType(Enum): TEMPERATURE = "temperatureModule" MAGNETICBLOCK = "magneticBlock" ABSORBANCE_READER = "absorbanceReader" + LID_DOCK = "lidDock" @dataclass(frozen=True) diff --git a/api/src/opentrons/types.py b/api/src/opentrons/types.py index 49b3476e489..324b6a23d23 100644 --- a/api/src/opentrons/types.py +++ b/api/src/opentrons/types.py @@ -282,6 +282,23 @@ def from_primitive(cls, value: DeckLocation) -> DeckSlotName: str_val = str(value).upper() return cls(str_val) + @classmethod + def ot3_slots(cls) -> List["DeckSlotName"]: + return [ + DeckSlotName.SLOT_A1, + DeckSlotName.SLOT_A2, + DeckSlotName.SLOT_A3, + DeckSlotName.SLOT_B1, + DeckSlotName.SLOT_B2, + DeckSlotName.SLOT_B3, + DeckSlotName.SLOT_C1, + DeckSlotName.SLOT_C2, + DeckSlotName.SLOT_C3, + DeckSlotName.SLOT_D1, + DeckSlotName.SLOT_D2, + DeckSlotName.SLOT_D3, + ] + # TODO(mm, 2023-05-08): # Migrate callers off of this method. https://opentrons.atlassian.net/browse/RLAB-345 def as_int(self) -> int: diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index 1071e46f68c..613ee3cfbe3 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -76,16 +76,16 @@ def test_initialize( assert subject._initialized_value == 123 -def test_initiate_read( +def test_read( decoy: Decoy, mock_engine_client: EngineClient, subject: AbsorbanceReaderCore ) -> None: """It should call absorbance reader to read with the engine client.""" subject._initialized_value = 123 - subject.initiate_read() + subject.read() decoy.verify( mock_engine_client.execute_command( - cmd.absorbance_reader.MeasureAbsorbanceParams( + cmd.absorbance_reader.ReadAbsorbanceParams( moduleId="1234", sampleWavelength=123, ), diff --git a/api/tests/opentrons/protocol_api/test_absorbance_reader_context.py b/api/tests/opentrons/protocol_api/test_absorbance_reader_context.py index 132e6d1cae6..61b65755350 100644 --- a/api/tests/opentrons/protocol_api/test_absorbance_reader_context.py +++ b/api/tests/opentrons/protocol_api/test_absorbance_reader_context.py @@ -4,7 +4,7 @@ from opentrons.legacy_broker import LegacyBroker from opentrons.protocols.api_support.types import APIVersion -from opentrons.protocol_api import MAX_SUPPORTED_VERSION, AbsorbanceReaderContext +from opentrons.protocol_api import AbsorbanceReaderContext from opentrons.protocol_api.core.common import ProtocolCore, AbsorbanceReaderCore from opentrons.protocol_api.core.core_map import LoadedCoreMap @@ -36,7 +36,7 @@ def mock_broker(decoy: Decoy) -> LegacyBroker: @pytest.fixture def api_version() -> APIVersion: """Get an API version to apply to the interface.""" - return MAX_SUPPORTED_VERSION + return APIVersion(2, 21) @pytest.fixture diff --git a/api/tests/opentrons/protocol_engine/conftest.py b/api/tests/opentrons/protocol_engine/conftest.py index a44548e0de3..7040f8497ea 100644 --- a/api/tests/opentrons/protocol_engine/conftest.py +++ b/api/tests/opentrons/protocol_engine/conftest.py @@ -98,6 +98,14 @@ def ot3_fixed_trash_def() -> LabwareDefinition: ) +@pytest.fixture(scope="session") +def ot3_absorbance_reader_lid() -> LabwareDefinition: + """Get the definition of the OT-3 plate reader lid.""" + return LabwareDefinition.parse_obj( + load_definition("opentrons_flex_lid_absorbance_plate_reader_module", 1) + ) + + @pytest.fixture(scope="session") def well_plate_def() -> LabwareDefinition: """Get the definition of a 96 well plate.""" diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py index 0caa1d52ac5..3c8552cdd6f 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py @@ -7,7 +7,12 @@ from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName -from opentrons.protocol_engine.types import DeckSlotLocation, DeckType +from opentrons.protocol_engine.types import ( + DeckSlotLocation, + DeckType, + DeckConfigurationType, + AddressableAreaLocation, +) from opentrons.protocol_engine.resources import ( LabwareDataProvider, DeckDataProvider, @@ -61,7 +66,7 @@ async def test_get_deck_labware_fixtures_ot2_standard( ) ).then_return(ot2_fixed_trash_def) - result = await subject.get_deck_fixed_labware(ot2_standard_deck_def) + result = await subject.get_deck_fixed_labware(True, ot2_standard_deck_def, None) assert result == [ DeckFixedLabware( @@ -91,7 +96,7 @@ async def test_get_deck_labware_fixtures_ot2_short_trash( ) ).then_return(ot2_short_fixed_trash_def) - result = await subject.get_deck_fixed_labware(ot2_short_trash_deck_def) + result = await subject.get_deck_fixed_labware(True, ot2_short_trash_deck_def, None) assert result == [ DeckFixedLabware( @@ -121,7 +126,7 @@ async def test_get_deck_labware_fixtures_ot3_standard( ) ).then_return(ot3_fixed_trash_def) - result = await subject.get_deck_fixed_labware(ot3_standard_deck_def) + result = await subject.get_deck_fixed_labware(True, ot3_standard_deck_def, None) assert result == [ DeckFixedLabware( @@ -130,3 +135,56 @@ async def test_get_deck_labware_fixtures_ot3_standard( definition=ot3_fixed_trash_def, ) ] + + +def _make_deck_config_with_plate_reader() -> DeckConfigurationType: + return [ + ("cutoutA1", "singleLeftSlot", None), + ("cutoutB1", "singleLeftSlot", None), + ("cutoutC1", "singleLeftSlot", None), + ("cutoutD1", "singleLeftSlot", None), + ("cutoutA2", "singleCenterSlot", None), + ("cutoutB2", "singleCenterSlot", None), + ("cutoutC2", "singleCenterSlot", None), + ("cutoutD2", "singleCenterSlot", None), + ("cutoutA3", "singleRightSlot", None), + ("cutoutB3", "singleRightSlot", None), + ("cutoutC3", "singleRightSlot", None), + ("cutoutD3", "absorbanceReaderV1", "abc123"), + ] + + +async def test_get_deck_labware_fixtures_ot3_standard_for_plate_reader( + decoy: Decoy, + ot3_standard_deck_def: DeckDefinitionV5, + ot3_absorbance_reader_lid: LabwareDefinition, + mock_labware_data_provider: LabwareDataProvider, +) -> None: + """It should get a lis including the Plate Reader Lid for our deck fixed labware.""" + subject = DeckDataProvider( + deck_type=DeckType.OT3_STANDARD, labware_data=mock_labware_data_provider + ) + + decoy.when( + await mock_labware_data_provider.get_labware_definition( + load_name="opentrons_flex_lid_absorbance_plate_reader_module", + namespace="opentrons", + version=1, + ) + ).then_return(ot3_absorbance_reader_lid) + + deck_config = _make_deck_config_with_plate_reader() + + result = await subject.get_deck_fixed_labware( + False, ot3_standard_deck_def, deck_config + ) + + assert result == [ + DeckFixedLabware( + labware_id="absorbanceReaderV1LidD3", + location=AddressableAreaLocation( + addressableAreaName="absorbanceReaderV1D3" + ), + definition=ot3_absorbance_reader_lid, + ) + ] diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 1f085b526f1..f23d8f4a6e1 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -147,7 +147,9 @@ def labware_view(labware_store: LabwareStore) -> LabwareView: @pytest.fixture def module_store(state_config: Config) -> ModuleStore: """Get a module store that can accept actions.""" - return ModuleStore(config=state_config, module_calibration_offsets={}) + return ModuleStore( + config=state_config, deck_fixed_labware=[], module_calibration_offsets={} + ) @pytest.fixture diff --git a/api/tests/opentrons/protocol_engine/state/test_module_store.py b/api/tests/opentrons/protocol_engine/state/test_module_store.py index f052056aa35..5a26fc97d1a 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_store.py @@ -96,6 +96,7 @@ def test_initial_state() -> None: """It should initialize the module state.""" subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) assert subject.state == ModuleState( @@ -106,6 +107,7 @@ def test_initial_state() -> None: substate_by_module_id={}, module_offset_by_serial={}, additional_slots_occupied_by_module_id={}, + deck_fixed_labware=[], ) @@ -206,6 +208,7 @@ def test_load_module( subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action(action) @@ -222,6 +225,7 @@ def test_load_module( substate_by_module_id={"module-id": expected_substate}, module_offset_by_serial={}, additional_slots_occupied_by_module_id={}, + deck_fixed_labware=[], ) @@ -272,6 +276,7 @@ def test_load_thermocycler_in_thermocycler_slot( robot_type=robot_type, deck_type=deck_type, ), + deck_fixed_labware=[], ) subject.handle_action(action) @@ -352,6 +357,7 @@ def test_add_module_action( subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action(action) @@ -368,6 +374,7 @@ def test_add_module_action( substate_by_module_id={"module-id": expected_substate}, module_offset_by_serial={}, additional_slots_occupied_by_module_id={}, + deck_fixed_labware=[], ) @@ -395,6 +402,7 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) ) subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action( @@ -448,6 +456,7 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non ) subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action( @@ -503,6 +512,7 @@ def test_handle_hs_labware_latch_commands( ) subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action( @@ -569,6 +579,7 @@ def test_handle_tempdeck_temperature_commands( ) subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action( @@ -630,6 +641,7 @@ def test_handle_thermocycler_temperature_commands( ) subject = ModuleStore( config=_OT2_STANDARD_CONFIG, + deck_fixed_labware=[], ) subject.handle_action( @@ -713,6 +725,7 @@ def test_handle_thermocycler_lid_commands( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, ), + deck_fixed_labware=[], ) subject.handle_action( diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index c7c67aa7e61..95b868497d2 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -126,6 +126,7 @@ def make_module_view( module_offset_by_serial=module_offset_by_serial or {}, additional_slots_occupied_by_module_id=additional_slots_occupied_by_module_id or {}, + deck_fixed_labware=[], ) return ModuleView(state=state) diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index 919c22212ec..70a31da2a0d 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -3,6 +3,7 @@ "about_module": "About {{name}}", "about_pipette_name": "About {{name}} Pipette", "about_pipette": "About pipette", + "abs_reader_status": "Absorbance Plate Reader Status", "add_fixture_description": "Add this hardware to your deck configuration. It will be referenced during protocol analysis.", "add_to_slot": "Add to slot {{slotName}}", "add": "Add", diff --git a/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts b/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts index 941a5b261b4..47b709a80d5 100644 --- a/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts +++ b/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts @@ -1,5 +1,8 @@ import omitBy from 'lodash/omitBy' -import { MAGNETIC_BLOCK_TYPE } from '@opentrons/shared-data' +import { + MAGNETIC_BLOCK_TYPE, + ABSORBANCE_READER_TYPE, +} from '@opentrons/shared-data' import { useModuleRenderInfoForProtocolById } from './useModuleRenderInfoForProtocolById' import { useIsFlex } from './useIsFlex' import type { ProtocolCalibrationStatus } from './useRunCalibrationStatus' @@ -13,7 +16,8 @@ export function useModuleCalibrationStatus( const moduleRenderInfoForProtocolById = omitBy( useModuleRenderInfoForProtocolById(runId), moduleRenderInfo => - moduleRenderInfo.moduleDef.moduleType === MAGNETIC_BLOCK_TYPE + moduleRenderInfo.moduleDef.moduleType === MAGNETIC_BLOCK_TYPE || + moduleRenderInfo.moduleDef.moduleType === ABSORBANCE_READER_TYPE ) // only check module calibration for Flex diff --git a/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx b/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx index ee512436f28..2e755eacbb7 100644 --- a/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx @@ -9,7 +9,7 @@ import { CONFIG_STYLE_EDITABLE, CONFIG_STYLE_READ_ONLY, FIXTURE_HEIGHT, - COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, + STAGING_AREA_FIXTURE_WIDTH, Y_ADJUSTMENT, CONFIG_STYLE_SELECTED, } from './constants' @@ -53,7 +53,6 @@ export function AbsorbanceReaderFixture( * so, to get the position of the cutout itself we must add an adjustment to the slot position * the adjustment for x is different for right side/left side */ - // TODO (AA): fix the slot length for the absorbance reader fixture const [xSlotPosition = 0, ySlotPosition = 0] = cutoutDef?.position ?? [] const x = xSlotPosition + COLUMN_3_X_ADJUSTMENT @@ -63,7 +62,7 @@ export function AbsorbanceReaderFixture( const editableStyle = selected ? CONFIG_STYLE_SELECTED : CONFIG_STYLE_EDITABLE return (