diff --git a/api/src/opentrons/protocol_api/core/engine/labware.py b/api/src/opentrons/protocol_api/core/engine/labware.py index d462401927f..6a9ea743355 100644 --- a/api/src/opentrons/protocol_api/core/engine/labware.py +++ b/api/src/opentrons/protocol_api/core/engine/labware.py @@ -122,7 +122,7 @@ def set_calibration(self, delta: Point) -> None: request = LabwareOffsetCreate.model_construct( definitionUri=self.get_uri(), - location=offset_location, + locationSequence=offset_location, vector=LabwareOffsetVector(x=delta.x, y=delta.y, z=delta.z), ) self._engine_client.add_labware_offset(request) diff --git a/api/src/opentrons/protocol_api/core/legacy/labware_offset_provider.py b/api/src/opentrons/protocol_api/core/legacy/labware_offset_provider.py index 0a78fe4f491..e0a4b4f6bd2 100644 --- a/api/src/opentrons/protocol_api/core/legacy/labware_offset_provider.py +++ b/api/src/opentrons/protocol_api/core/legacy/labware_offset_provider.py @@ -85,7 +85,7 @@ def find( See the parent class for param details. """ - offset = self._labware_view.find_applicable_labware_offset( + offset = self._labware_view.find_applicable_labware_offset_by_legacy_location( definition_uri=load_params.as_uri(), location=LegacyLabwareOffsetLocation( slotName=deck_slot, diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 0ec505d68e6..680994ce70c 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -23,7 +23,7 @@ from ..notes.notes import CommandNote from ..state.update_types import StateUpdate from ..types import ( - LabwareOffsetCreate, + LabwareOffsetCreateInternal, ModuleDefinition, Liquid, DeckConfigurationType, @@ -206,7 +206,7 @@ class AddLabwareOffsetAction: labware_offset_id: str created_at: datetime - request: LabwareOffsetCreate + request: LabwareOffsetCreateInternal @dataclasses.dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 2b0fb6a6060..85d89e8e2fb 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -82,6 +82,7 @@ InvalidLiquidError, LiquidClassDoesNotExistError, LiquidClassRedefinitionError, + OffsetLocationInvalidError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -160,6 +161,7 @@ "LocationIsLidDockSlotError", "InvalidAxisForRobotType", "NotSupportedOnRobotType", + "OffsetLocationInvalidError", # error occurrence models "ErrorOccurrence", "CommandNotAllowedError", diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index c3fddf99a61..3aa7c0562ab 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -433,6 +433,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class OffsetLocationInvalidError(ProtocolEngineError): + """Raised when encountering an invalid labware offset location sequence.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an OffsetLocationSequenceDoesNotTerminateAtAnAddressableAreaError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class SlotDoesNotExistError(ProtocolEngineError): """Raised when referencing a deck slot that does not exist.""" diff --git a/api/src/opentrons/protocol_engine/execution/equipment.py b/api/src/opentrons/protocol_engine/execution/equipment.py index 54037cf3da0..4a487247e08 100644 --- a/api/src/opentrons/protocol_engine/execution/equipment.py +++ b/api/src/opentrons/protocol_engine/execution/equipment.py @@ -1,4 +1,5 @@ """Equipment command side-effect logic.""" + from dataclasses import dataclass from typing import Optional, overload, Union, List @@ -42,10 +43,7 @@ from ..types import ( LabwareLocation, DeckSlotLocation, - ModuleLocation, - OnLabwareLocation, LabwareOffset, - LegacyLabwareOffsetLocation, ModuleModel, ModuleDefinition, AddressableAreaLocation, @@ -633,8 +631,9 @@ def find_applicable_labware_offset_id( or None if no labware offset will apply. """ labware_offset_location = ( - self._get_labware_offset_location_from_labware_location(labware_location) + self._state_store.geometry.get_projected_offset_location(labware_location) ) + if labware_offset_location is None: # No offset for off-deck location. # Returning None instead of raising an exception allows loading a labware @@ -647,74 +646,6 @@ def find_applicable_labware_offset_id( ) return self._get_id_from_offset(offset) - def _get_labware_offset_location_from_labware_location( - self, labware_location: LabwareLocation - ) -> Optional[LegacyLabwareOffsetLocation]: - if isinstance(labware_location, DeckSlotLocation): - return LegacyLabwareOffsetLocation(slotName=labware_location.slotName) - elif isinstance(labware_location, ModuleLocation): - module_id = labware_location.moduleId - # Allow ModuleNotLoadedError to propagate. - # Note also that we match based on the module's requested model, not its - # actual model, to implement robot-server's documented HTTP API semantics. - module_model = self._state_store.modules.get_requested_model( - module_id=module_id - ) - - # If `module_model is None`, it probably means that this module was added by - # `ProtocolEngine.use_attached_modules()`, instead of an explicit - # `loadModule` command. - # - # This assert should never raise in practice because: - # 1. `ProtocolEngine.use_attached_modules()` is only used by - # robot-server's "stateless command" endpoints, under `/commands`. - # 2. Those endpoints don't support loading labware, so this code will - # never run. - # - # Nevertheless, if it does happen somehow, we do NOT want to pass the - # `None` value along to `LabwareView.find_applicable_labware_offset()`. - # `None` means something different there, which will cause us to return - # wrong results. - assert module_model is not None, ( - "Can't find offsets for labware" - " that are loaded on modules" - " that were loaded with ProtocolEngine.use_attached_modules()." - ) - - module_location = self._state_store.modules.get_location( - module_id=module_id - ) - slot_name = module_location.slotName - return LegacyLabwareOffsetLocation( - slotName=slot_name, moduleModel=module_model - ) - elif isinstance(labware_location, OnLabwareLocation): - parent_labware_id = labware_location.labwareId - parent_labware_uri = self._state_store.labware.get_definition_uri( - parent_labware_id - ) - - base_location = self._state_store.labware.get_parent_location( - parent_labware_id - ) - base_labware_offset_location = ( - self._get_labware_offset_location_from_labware_location(base_location) - ) - if base_labware_offset_location is None: - # No offset for labware sitting on labware off-deck - return None - - # If labware is being stacked on itself, all labware in the stack will share a labware offset due to - # them sharing the same definitionUri in `LegacyLabwareOffsetLocation`. This will not be true for the - # bottom-most labware, which will have a `DeckSlotLocation` and have its definitionUri field empty. - return LegacyLabwareOffsetLocation( - slotName=base_labware_offset_location.slotName, - moduleModel=base_labware_offset_location.moduleModel, - definitionUri=parent_labware_uri, - ) - else: # Off deck - return None - @staticmethod def _get_id_from_offset(labware_offset: Optional[LabwareOffset]) -> Optional[str]: return None if labware_offset is None else labware_offset.id diff --git a/api/src/opentrons/protocol_engine/labware_offset_standardization.py b/api/src/opentrons/protocol_engine/labware_offset_standardization.py new file mode 100644 index 00000000000..f56be63e975 --- /dev/null +++ b/api/src/opentrons/protocol_engine/labware_offset_standardization.py @@ -0,0 +1,172 @@ +"""Convert labware offset creation requests and stored elements between legacy and new.""" + +from opentrons_shared_data.robot.types import RobotType +from opentrons_shared_data.deck.types import DeckDefinitionV5 +from .errors import ( + OffsetLocationInvalidError, + FixtureDoesNotExistError, +) +from .types import ( + LabwareOffsetCreate, + LegacyLabwareOffsetCreate, + LabwareOffsetCreateInternal, + LegacyLabwareOffsetLocation, + LabwareOffsetLocationSequence, + OnLabwareOffsetLocationSequenceComponent, + OnAddressableAreaOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, + ModuleModel, +) +from .resources import deck_configuration_provider + + +def standardize_labware_offset_create( + request: LabwareOffsetCreate | LegacyLabwareOffsetCreate, + robot_type: RobotType, + deck_definition: DeckDefinitionV5, +) -> LabwareOffsetCreateInternal: + """Turn a union of old and new labware offset create requests into a new one.""" + location_sequence, legacy_location = _locations_for_create( + request, robot_type, deck_definition + ) + return LabwareOffsetCreateInternal( + definitionUri=request.definitionUri, + locationSequence=location_sequence, + legacyLocation=legacy_location, + vector=request.vector, + ) + + +def _legacy_offset_location_to_offset_location_sequence( + location: LegacyLabwareOffsetLocation, deck_definition: DeckDefinitionV5 +) -> LabwareOffsetLocationSequence: + sequence: LabwareOffsetLocationSequence = [] + if location.definitionUri: + sequence.append( + OnLabwareOffsetLocationSequenceComponent(labwareUri=location.definitionUri) + ) + if location.moduleModel: + sequence.append( + OnModuleOffsetLocationSequenceComponent(moduleModel=location.moduleModel) + ) + cutout_id = deck_configuration_provider.get_cutout_id_by_deck_slot_name( + location.slotName + ) + possible_cutout_fixture_id = location.moduleModel.value + try: + addressable_area = deck_configuration_provider.get_labware_hosting_addressable_area_name_for_cutout_and_cutout_fixture( + cutout_id, possible_cutout_fixture_id, deck_definition + ) + sequence.append( + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName=addressable_area + ) + ) + except FixtureDoesNotExistError: + # this is an OT-2 (or this module isn't supported in the deck definition) and we should use a + # slot addressable area name + sequence.append( + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName=location.slotName.value + ) + ) + + else: + # Slight hack: we should have a more formal association here. However, since the slot + # name is already standardized, and since the addressable areas for slots are just the + # name of the slots, we can rely on this. + sequence.append( + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName=location.slotName.value + ) + ) + return sequence + + +def _offset_location_sequence_head_to_labware_and_module( + location_sequence: LabwareOffsetLocationSequence, deck_definition: DeckDefinitionV5 +) -> tuple[ModuleModel | None, str | None]: + labware_uri: str | None = None + module_model: ModuleModel | None = None + for location in location_sequence: + if isinstance(location, OnAddressableAreaOffsetLocationSequenceComponent): + raise OffsetLocationInvalidError( + "Addressable areas may only be the final element of an offset location." + ) + elif isinstance(location, OnLabwareOffsetLocationSequenceComponent): + if labware_uri is not None: + # We only take the first location + continue + if module_model is not None: + # Labware can't be underneath modules + raise OffsetLocationInvalidError( + "Labware must not be underneath a module." + ) + labware_uri = location.labwareUri + elif isinstance(location, OnModuleOffsetLocationSequenceComponent): + if module_model is not None: + # Bad, somebody put more than one module in here + raise OffsetLocationInvalidError( + "Only one module location may exist in an offset location." + ) + module_model = location.moduleModel + else: + raise OffsetLocationInvalidError( + f"Invalid location component in offset location: {repr(location)}" + ) + return module_model, labware_uri + + +def _offset_location_sequence_to_legacy_offset_location( + location_sequence: LabwareOffsetLocationSequence, deck_definition: DeckDefinitionV5 +) -> LegacyLabwareOffsetLocation: + if len(location_sequence) == 0: + raise OffsetLocationInvalidError( + "Offset locations must contain at least one component." + ) + last_element = location_sequence[-1] + if not isinstance(last_element, OnAddressableAreaOffsetLocationSequenceComponent): + raise OffsetLocationInvalidError( + "Offset locations must end with an addressable area." + ) + module_model, labware_uri = _offset_location_sequence_head_to_labware_and_module( + location_sequence[:-1], deck_definition + ) + ( + cutout_id, + cutout_fixtures, + ) = deck_configuration_provider.get_potential_cutout_fixtures( + last_element.addressableAreaName, deck_definition + ) + slot_name = deck_configuration_provider.get_deck_slot_for_cutout_id(cutout_id) + return LegacyLabwareOffsetLocation( + slotName=slot_name, moduleModel=module_model, definitionUri=labware_uri + ) + + +def _locations_for_create( + request: LabwareOffsetCreate | LegacyLabwareOffsetCreate, + robot_type: RobotType, + deck_definition: DeckDefinitionV5, +) -> tuple[LabwareOffsetLocationSequence, LegacyLabwareOffsetLocation]: + if isinstance(request, LabwareOffsetCreate): + return ( + request.locationSequence, + _offset_location_sequence_to_legacy_offset_location( + request.locationSequence, deck_definition + ), + ) + else: + normalized = request.location.model_copy( + update={ + "slotName": request.location.slotName.to_equivalent_for_robot_type( + robot_type + ) + } + ) + return ( + _legacy_offset_location_to_offset_location_sequence( + normalized, deck_definition + ), + normalized, + ) diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index d1636d18001..04579efc590 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -1,4 +1,5 @@ """ProtocolEngine class definition.""" + from contextlib import AsyncExitStack from logging import getLogger from typing import Dict, Optional, Union, AsyncGenerator, Callable @@ -20,11 +21,12 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError from .error_recovery_policy import ErrorRecoveryPolicy -from . import commands, slot_standardization +from . import commands, slot_standardization, labware_offset_standardization from .resources import ModelUtils, ModuleDataProvider, FileProvider from .types import ( LabwareOffset, LabwareOffsetCreate, + LegacyLabwareOffsetCreate, LabwareUri, ModuleModel, Liquid, @@ -517,15 +519,21 @@ async def finish( ) ) - def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset: + def add_labware_offset( + self, request: LabwareOffsetCreate | LegacyLabwareOffsetCreate + ) -> LabwareOffset: """Add a new labware offset and return it. The added offset will apply to subsequent `LoadLabwareCommand`s. To retrieve offsets later, see `.state_view.labware`. """ - request = slot_standardization.standardize_labware_offset( - request, self.state_view.config.robot_type + internal_request = ( + labware_offset_standardization.standardize_labware_offset_create( + request, + self.state_view.config.robot_type, + self.state_view.addressable_areas.deck_definition, + ) ) labware_offset_id = self._model_utils.generate_id() @@ -534,7 +542,7 @@ def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset: AddLabwareOffsetAction( labware_offset_id=labware_offset_id, created_at=created_at, - request=request, + request=internal_request, ) ) return self.state_view.labware.get_labware_offset( diff --git a/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py b/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py index e5c1d47610c..6ec09136387 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py @@ -21,6 +21,7 @@ CutoutDoesNotExistError, FixtureDoesNotExistError, AddressableAreaDoesNotExistError, + SlotDoesNotExistError, ) @@ -107,9 +108,10 @@ def get_addressable_area_from_name( """Given a name and a cutout position, get an addressable area on the deck.""" for addressable_area in deck_definition["locations"]["addressableAreas"]: if addressable_area["id"] == addressable_area_name: - base_slot = get_deck_slot_for_addressable_area_name( + cutout_id, _ = get_potential_cutout_fixtures( addressable_area_name, deck_definition ) + base_slot = get_deck_slot_for_cutout_id(cutout_id) area_offset = addressable_area["offsetFromCutoutFixture"] position = AddressableOffsetVector( x=area_offset[0] + cutout_position.x, @@ -138,22 +140,51 @@ def get_addressable_area_from_name( ) -def get_deck_slot_for_addressable_area_name( - addressable_area_name: str, deck_definition: DeckDefinitionV5 -) -> DeckSlotName: +def get_deck_slot_for_cutout_id(cutout_id: str) -> DeckSlotName: """Get the corresponding deck slot for an addressable area.""" - for cutout_fixture in deck_definition["cutoutFixtures"]: - for cutout, provided in cutout_fixture["providesAddressableAreas"].items(): - if addressable_area_name in provided: - return CUTOUT_TO_DECK_SLOT_MAP[cutout] - raise AddressableAreaDoesNotExistError( - f"Could not find addressable area with name {addressable_area_name}" - ) + try: + return CUTOUT_TO_DECK_SLOT_MAP[cutout_id] + except KeyError: + raise CutoutDoesNotExistError(f"Could not find data for cutout {cutout_id}") def get_cutout_id_by_deck_slot_name(slot_name: DeckSlotName) -> str: """Get the Cutout ID of a given Deck Slot by Deck Slot Name.""" - return DECK_SLOT_TO_CUTOUT_MAP[slot_name] + try: + return DECK_SLOT_TO_CUTOUT_MAP[slot_name] + except KeyError: + raise SlotDoesNotExistError(f"Could not find data for slot {slot_name.value}") + + +def get_labware_hosting_addressable_area_name_for_cutout_and_cutout_fixture( + cutout_id: str, cutout_fixture_id: str, deck_definition: DeckDefinitionV5 +) -> str: + """Get the first addressable area that can contain labware for a cutout and fixture. + + This probably isn't relevant outside of labware offset locations, where (for now) nothing + provides more than one labware-containing addressable area. + """ + for cutoutFixture in deck_definition["cutoutFixtures"]: + if cutoutFixture["id"] != cutout_fixture_id: + continue + provided_aas = cutoutFixture["providesAddressableAreas"].get(cutout_id, None) + if provided_aas is None: + raise CutoutDoesNotExistError( + f"{cutout_fixture_id} does not go in {cutout_id}" + ) + for aa_id in provided_aas: + for addressable_area in deck_definition["locations"]["addressableAreas"]: + if addressable_area["id"] != aa_id: + continue + # TODO: In deck def v6 this will be easier, but as of right now there isn't really + # a way to tell from an addressable area whether it takes labware so let's take the + # first one + return aa_id + raise AddressableAreaDoesNotExistError( + f"Could not find an addressable area that allows labware from cutout fixture {cutout_fixture_id} in cutout {cutout_id}" + ) + + raise FixtureDoesNotExistError(f"Could not find entry for {cutout_fixture_id}") # This is a temporary shim while Protocol Engine's conflict-checking code diff --git a/api/src/opentrons/protocol_engine/slot_standardization.py b/api/src/opentrons/protocol_engine/slot_standardization.py index 5943febc820..935bb54da3f 100644 --- a/api/src/opentrons/protocol_engine/slot_standardization.py +++ b/api/src/opentrons/protocol_engine/slot_standardization.py @@ -14,7 +14,6 @@ deck slot. """ - from typing import Any, Callable, Dict, Type from opentrons_shared_data.robot.types import RobotType @@ -26,29 +25,11 @@ DeckSlotLocation, LabwareLocation, AddressableAreaLocation, - LabwareOffsetCreate, ModuleLocation, OnLabwareLocation, ) -def standardize_labware_offset( - original: LabwareOffsetCreate, robot_type: RobotType -) -> LabwareOffsetCreate: - """Convert the deck slot in the given `LabwareOffsetCreate` to match the given robot type.""" - return original.model_copy( - update={ - "location": original.location.model_copy( - update={ - "slotName": original.location.slotName.to_equivalent_for_robot_type( - robot_type - ) - } - ) - } - ) - - def standardize_command( original: commands.CommandCreate, robot_type: RobotType ) -> commands.CommandCreate: diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 6172aeff667..c227fa72285 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -260,6 +260,11 @@ def __init__(self, state: AddressableAreaState) -> None: """ self._state = state + @cached_property + def deck_definition(self) -> DeckDefinitionV5: + """The full deck definition.""" + return self._state.deck_definition + @cached_property def deck_extents(self) -> Point: """The maximum space on the deck.""" diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 48d3f536c3f..2a4b79571cc 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -51,7 +51,10 @@ AddressableAreaLocation, AddressableOffsetVector, StagingSlotLocation, - LegacyLabwareOffsetLocation, + LabwareOffsetLocationSequence, + OnModuleOffsetLocationSequenceComponent, + OnAddressableAreaOffsetLocationSequenceComponent, + OnLabwareOffsetLocationSequenceComponent, ModuleModel, ) from .config import Config @@ -1362,50 +1365,89 @@ def _labware_gripper_offsets( def get_offset_location( self, labware_id: str - ) -> Optional[LegacyLabwareOffsetLocation]: + ) -> Optional[LabwareOffsetLocationSequence]: """Provide the LegacyLabwareOffsetLocation specifying the current position of the labware. - If the labware is in a location that cannot be specified by a LegacyLabwareOffsetLocation + If the labware is in a location that cannot be specified by a LabwareOffsetLocationSequence (for instance, OFF_DECK) then return None. """ parent_location = self._labware.get_location(labware_id) - - if isinstance(parent_location, DeckSlotLocation): - return LegacyLabwareOffsetLocation( - slotName=parent_location.slotName, moduleModel=None, definitionUri=None - ) - elif isinstance(parent_location, ModuleLocation): - module_model = self._modules.get_requested_model(parent_location.moduleId) - module_location = self._modules.get_location(parent_location.moduleId) - return LegacyLabwareOffsetLocation( - slotName=module_location.slotName, - moduleModel=module_model, - definitionUri=None, - ) - elif isinstance(parent_location, OnLabwareLocation): - non_labware_parent_location = self._labware.get_parent_location(labware_id) - - parent_uri = self._labware.get_definition_uri(parent_location.labwareId) - if isinstance(non_labware_parent_location, DeckSlotLocation): - return LegacyLabwareOffsetLocation( - slotName=non_labware_parent_location.slotName, - moduleModel=None, - definitionUri=parent_uri, + return self.get_projected_offset_location(parent_location) + + def get_projected_offset_location( + self, labware_location: LabwareLocation + ) -> Optional[LabwareOffsetLocationSequence]: + """Get the offset location that a labware loaded into this location would match.""" + return self._recurse_labware_offset_location(labware_location, []) + + def _recurse_labware_offset_location( + self, labware_location: LabwareLocation, building: LabwareOffsetLocationSequence + ) -> LabwareOffsetLocationSequence | None: + if isinstance(labware_location, DeckSlotLocation): + return building + [ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName=labware_location.slotName.value ) - elif isinstance(non_labware_parent_location, ModuleLocation): - module_model = self._modules.get_requested_model( - non_labware_parent_location.moduleId - ) - module_location = self._modules.get_location( - non_labware_parent_location.moduleId - ) - return LegacyLabwareOffsetLocation( - slotName=module_location.slotName, - moduleModel=module_model, - definitionUri=parent_uri, + ] + + elif isinstance(labware_location, ModuleLocation): + module_id = labware_location.moduleId + # Allow ModuleNotLoadedError to propagate. + # Note also that we match based on the module's requested model, not its + # actual model, to implement robot-server's documented HTTP API semantics. + module_model = self._modules.get_requested_model(module_id=module_id) + + # If `module_model is None`, it probably means that this module was added by + # `ProtocolEngine.use_attached_modules()`, instead of an explicit + # `loadModule` command. + # + # This assert should never raise in practice because: + # 1. `ProtocolEngine.use_attached_modules()` is only used by + # robot-server's "stateless command" endpoints, under `/commands`. + # 2. Those endpoints don't support loading labware, so this code will + # never run. + # + # Nevertheless, if it does happen somehow, we do NOT want to pass the + # `None` value along to `LabwareView.find_applicable_labware_offset()`. + # `None` means something different there, which will cause us to return + # wrong results. + assert module_model is not None, ( + "Can't find offsets for labware" + " that are loaded on modules" + " that were loaded with ProtocolEngine.use_attached_modules()." + ) + + module_location = self._modules.get_location(module_id=module_id) + if self._modules.get_deck_supports_module_fixtures(): + module_aa = self._modules.ensure_and_convert_module_fixture_location( + module_location.slotName, module_model ) + else: + module_aa = module_location.slotName.value + return building + [ + OnModuleOffsetLocationSequenceComponent(moduleModel=module_model), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName=module_aa + ), + ] + + elif isinstance(labware_location, OnLabwareLocation): + parent_labware_id = labware_location.labwareId + parent_labware_uri = self._labware.get_definition_uri(parent_labware_id) + + base_location = self._labware.get_parent_location(parent_labware_id) + return self._recurse_labware_offset_location( + base_location, + building + + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri=parent_labware_uri + ) + ], + ) - return None + else: # Off deck + return None def get_well_offset_adjustment( self, diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 83b51b86647..6063a46e6b8 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -1,4 +1,5 @@ """Basic labware data state and store.""" + from __future__ import annotations from dataclasses import dataclass @@ -41,6 +42,7 @@ Dimensions, LabwareOffset, LabwareOffsetVector, + LabwareOffsetLocationSequence, LegacyLabwareOffsetLocation, LabwareLocation, LoadedLabware, @@ -167,7 +169,8 @@ def handle_action(self, action: Action) -> None: id=action.labware_offset_id, createdAt=action.created_at, definitionUri=action.request.definitionUri, - location=action.request.location, + location=action.request.legacyLocation, + locationSequence=action.request.locationSequence, vector=action.request.vector, ) self._add_labware_offset(labware_offset) @@ -825,15 +828,32 @@ def get_labware_offsets(self) -> List[LabwareOffset]: """Get all labware offsets, in the order they were added.""" return list(self._state.labware_offsets_by_id.values()) - # TODO: Make this slightly more ergonomic for the caller by - # only returning the optional str ID, at the cost of baking redundant lookups - # into the API? def find_applicable_labware_offset( + self, definition_uri: str, location: LabwareOffsetLocationSequence + ) -> Optional[LabwareOffset]: + """Find a labware offset that applies to the given definition and location sequence. + + Returns the *most recently* added matching offset, so later ones can override earlier ones. + Returns ``None`` if no loaded offset matches the location. + + An offset matches a labware instance if the sequence of locations formed by following the + .location elements of the labware instance until you reach an addressable area has the same + definition URIs as the sequence of definition URIs stored by the offset. + """ + for candidate in reversed(list(self._state.labware_offsets_by_id.values())): + if ( + candidate.definitionUri == definition_uri + and candidate.locationSequence == location + ): + return candidate + return None + + def find_applicable_labware_offset_by_legacy_location( self, definition_uri: str, location: LegacyLabwareOffsetLocation, ) -> Optional[LabwareOffset]: - """Find a labware offset that applies to the given definition and location. + """Find a labware offset that applies to the given definition and legacy location. Returns the *most recently* added matching offset, so later offsets can override earlier ones. diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 76d7a084b42..2da186555fd 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -1293,6 +1293,11 @@ def convert_absorbance_reader_data_points( "Only readings of 96 Well labware are supported for conversion to map of values by well." ) + def get_deck_supports_module_fixtures(self) -> bool: + """Check if the loaded deck supports modules as fixtures.""" + deck_type = self._state.deck_type + return deck_type not in [DeckType.OT2_STANDARD, DeckType.OT2_SHORT_TRASH] + def ensure_and_convert_module_fixture_location( self, deck_slot: DeckSlotName, @@ -1304,7 +1309,7 @@ def ensure_and_convert_module_fixture_location( """ deck_type = self._state.deck_type - if deck_type == DeckType.OT2_STANDARD or deck_type == DeckType.OT2_SHORT_TRASH: + if not self.get_deck_supports_module_fixtures(): raise ValueError( f"Invalid Deck Type: {deck_type.name} - Does not support modules as fixtures." ) diff --git a/api/src/opentrons/protocol_engine/types/__init__.py b/api/src/opentrons/protocol_engine/types/__init__.py index 75cd1789d2f..fbaef870f3e 100644 --- a/api/src/opentrons/protocol_engine/types/__init__.py +++ b/api/src/opentrons/protocol_engine/types/__init__.py @@ -83,10 +83,18 @@ OverlapOffset, LabwareOffset, LabwareOffsetCreate, + LegacyLabwareOffsetCreate, + LabwareOffsetCreateInternal, LoadedLabware, ) from .liquid import HexColor, EmptyLiquidId, LiquidId, Liquid, FluidKind, AspiratedFluid -from .labware_offset_location import LegacyLabwareOffsetLocation +from .labware_offset_location import ( + LegacyLabwareOffsetLocation, + LabwareOffsetLocationSequence, + OnLabwareOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, + OnAddressableAreaOffsetLocationSequenceComponent, +) from .labware_offset_vector import LabwareOffsetVector from .well_position import ( WellOrigin, @@ -195,12 +203,18 @@ "DeckPoint", # Labware offset location "LegacyLabwareOffsetLocation", + "LabwareOffsetLocationSequence", + "OnLabwareOffsetLocationSequenceComponent", + "OnModuleOffsetLocationSequenceComponent", + "OnAddressableAreaOffsetLocationSequenceComponent", # Labware offset vector "LabwareOffsetVector", # Labware "OverlapOffset", "LabwareOffset", "LabwareOffsetCreate", + "LegacyLabwareOffsetCreate", + "LabwareOffsetCreateInternal", "LoadedLabware", "LabwareOffsetVector", # Liquids diff --git a/api/src/opentrons/protocol_engine/types/labware.py b/api/src/opentrons/protocol_engine/types/labware.py index 16a374711e6..bb8a4656d58 100644 --- a/api/src/opentrons/protocol_engine/types/labware.py +++ b/api/src/opentrons/protocol_engine/types/labware.py @@ -3,12 +3,16 @@ from __future__ import annotations from typing import Optional +from dataclasses import dataclass from datetime import datetime from pydantic import BaseModel, Field from .location import LabwareLocation -from .labware_offset_location import LegacyLabwareOffsetLocation +from .labware_offset_location import ( + LegacyLabwareOffsetLocation, + LabwareOffsetLocationSequence, +) from .labware_offset_vector import LabwareOffsetVector from .util import Vec3f @@ -30,7 +34,11 @@ class LabwareOffset(BaseModel): definitionUri: str = Field(..., description="The URI for the labware's definition.") location: LegacyLabwareOffsetLocation = Field( ..., - description="Where the labware is located on the robot.", + description="Where the labware is located on the robot. Deprecated and present only for backwards compatibility; cannot represent certain locations. Use locationSequence instead.", + ) + locationSequence: Optional[LabwareOffsetLocationSequence] = Field( + default=None, + description="Where the labware is located on the robot. Can represent all locations, but may not be present for older runs.", ) vector: LabwareOffsetVector = Field( ..., @@ -38,8 +46,8 @@ class LabwareOffset(BaseModel): ) -class LabwareOffsetCreate(BaseModel): - """Create request data for a labware offset.""" +class LegacyLabwareOffsetCreate(BaseModel): + """Create request data for a labware offset with a legacy location field.""" definitionUri: str = Field(..., description="The URI for the labware's definition.") location: LegacyLabwareOffsetLocation = Field( @@ -52,6 +60,28 @@ class LabwareOffsetCreate(BaseModel): ) +class LabwareOffsetCreate(BaseModel): + """Create request data for a labware offset with a modern location sequence.""" + + definitionUri: str = Field(..., description="The URI for the labware's definition.") + locationSequence: LabwareOffsetLocationSequence = Field( + ..., description="Where the labware is located on the robot." + ) + vector: LabwareOffsetVector = Field( + ..., description="The offset applied to matching labware." + ) + + +@dataclass(frozen=True) +class LabwareOffsetCreateInternal: + """An internal-only labware offset creator that captures both old and new location arguments.""" + + definitionUri: str + locationSequence: LabwareOffsetLocationSequence + legacyLocation: LegacyLabwareOffsetLocation + vector: LabwareOffsetVector + + class LoadedLabware(BaseModel): """A labware that has been loaded.""" diff --git a/api/src/opentrons/protocol_engine/types/labware_offset_location.py b/api/src/opentrons/protocol_engine/types/labware_offset_location.py index b31b69771b8..2b992a4da01 100644 --- a/api/src/opentrons/protocol_engine/types/labware_offset_location.py +++ b/api/src/opentrons/protocol_engine/types/labware_offset_location.py @@ -3,7 +3,7 @@ This is its own module to fix circular imports. """ -from typing import Optional +from typing import Optional, Literal from pydantic import BaseModel, Field @@ -12,6 +12,51 @@ from .module import ModuleModel +class OnLabwareOffsetLocationSequenceComponent(BaseModel): + """Offset location sequence component for a labware on another labware.""" + + kind: Literal["onLabware"] = "onLabware" + labwareUri: str = Field( + ..., + description="The definition URI of a labware that a labware can be loaded onto.", + ) + + +class OnModuleOffsetLocationSequenceComponent(BaseModel): + """Offset location sequence component for a labware on a module.""" + + kind: Literal["onModule"] = "onModule" + moduleModel: ModuleModel = Field( + ..., description="The model of a module that a lwbare can be loaded on to." + ) + + +class OnAddressableAreaOffsetLocationSequenceComponent(BaseModel): + """Offset location sequence component for a labware on an addressable area.""" + + kind: Literal["onAddressableArea"] = "onAddressableArea" + addressableAreaName: str = Field( + ..., + description=( + 'The ID of an addressable area that a labware or module can be loaded onto, such as (on the OT-2) "2" ' + 'or (on the Flex) "C1". ' + "\n\n" + "On the Flex, this field must be correct for the kind of entity it hosts. For instance, if the prior entity " + "in the location sequence is an `OnModuleOffsetLocationSequenceComponent(moduleModel=temperatureModuleV2)`, " + "this entity must be temperatureModuleV2NN where NN is the slot name in which the module resides. " + ), + ) + + +LabwareOffsetLocationSequenceComponents = ( + OnLabwareOffsetLocationSequenceComponent + | OnModuleOffsetLocationSequenceComponent + | OnAddressableAreaOffsetLocationSequenceComponent +) + +LabwareOffsetLocationSequence = list[LabwareOffsetLocationSequenceComponents] + + class LegacyLabwareOffsetLocation(BaseModel): """Parameters describing when a given offset may apply to a given labware load.""" diff --git a/api/src/opentrons/protocol_engine/types/location.py b/api/src/opentrons/protocol_engine/types/location.py index 8c0113832c1..cc174f4bdea 100644 --- a/api/src/opentrons/protocol_engine/types/location.py +++ b/api/src/opentrons/protocol_engine/types/location.py @@ -78,7 +78,7 @@ class OnLabwareLocation(BaseModel): SYSTEM_LOCATION: _SystemLocationType = "systemLocation" -class OnLabwareLocationVectorComponent(BaseModel): +class OnLabwareLocationSequenceComponent(BaseModel): """Labware on another labware.""" kind: Literal["onLabware"] = "onLabware" @@ -86,14 +86,14 @@ class OnLabwareLocationVectorComponent(BaseModel): lidId: str | None -class OnModuleLocationVectorComponent(BaseModel): +class OnModuleLocationSequenceComponent(BaseModel): """Labware on a module.""" kind: Literal["onModule"] = "onModule" moduleId: str -class OnAddressableAreaLocationVectorComponent(BaseModel): +class OnAddressableAreaLocationSequenceComponent(BaseModel): """Labware on an addressable area.""" kind: Literal["onAddressableArea"] = "onAddressableArea" @@ -101,18 +101,18 @@ class OnAddressableAreaLocationVectorComponent(BaseModel): slotName: str | None -class NotOnDeckLocationVectorComponent(BaseModel): +class NotOnDeckLocationSequenceComponent(BaseModel): """Labware on a system location.""" kind: Literal["notOnDeck"] = "notOnDeck" logicalLocationName: _OffDeckLocationType | _SystemLocationType -LabwareLocationVector = list[ - OnLabwareLocationVectorComponent - | OnModuleLocationVectorComponent - | OnAddressableAreaLocationVectorComponent - | NotOnDeckLocationVectorComponent +LabwareLocationSequence = list[ + OnLabwareLocationSequenceComponent + | OnModuleLocationSequenceComponent + | OnAddressableAreaLocationSequenceComponent + | NotOnDeckLocationSequenceComponent ] """Labware location specifier.""" diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 28266a9c485..b45d8b9db94 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -32,6 +32,7 @@ PostRunHardwareState, EngineStatus, LabwareOffsetCreate, + LegacyLabwareOffsetCreate, LabwareOffset, DeckConfigurationType, RunTimeParameter, @@ -346,7 +347,9 @@ def run_has_stopped(self) -> bool: """Get whether the run has stopped.""" return self._protocol_engine.state_view.commands.get_is_stopped() - def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset: + def add_labware_offset( + self, request: LabwareOffsetCreate | LegacyLabwareOffsetCreate + ) -> LabwareOffset: """Add a new labware offset to state.""" return self._protocol_engine.add_labware_offset(request) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_labware_core.py b/api/tests/opentrons/protocol_api/core/engine/test_labware_core.py index b04d8bf875b..06c2445d79e 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_labware_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_labware_core.py @@ -23,8 +23,9 @@ from opentrons.protocol_engine.errors import LabwareNotOnDeckError from opentrons.protocol_engine.types import ( LabwareOffsetCreate, - LegacyLabwareOffsetLocation, + LabwareOffsetLocationSequence, LabwareOffsetVector, + OnAddressableAreaOffsetLocationSequenceComponent, ) from opentrons.protocol_api._liquid import Liquid from opentrons.protocol_api.core.labware import LabwareLoadParams @@ -106,16 +107,18 @@ def test_set_calibration_succeeds_in_ok_location( decoy.when( mock_engine_client.state.labware.get_display_name("cool-labware") ).then_return("what a cool labware") - location = LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2) + location = [ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="C2") + ] decoy.when( mock_engine_client.state.geometry.get_offset_location("cool-labware") - ).then_return(location) + ).then_return(cast(LabwareOffsetLocationSequence, location)) subject.set_calibration(Point(1, 2, 3)) decoy.verify( mock_engine_client.add_labware_offset( LabwareOffsetCreate( definitionUri="hello/world/42", - location=location, + locationSequence=cast(LabwareOffsetLocationSequence, location), vector=LabwareOffsetVector(x=1, y=2, z=3), ) ), diff --git a/api/tests/opentrons/protocol_api/core/legacy/test_labware_offset_provider.py b/api/tests/opentrons/protocol_api/core/legacy/test_labware_offset_provider.py index d0027089f91..a31abe5b06b 100644 --- a/api/tests/opentrons/protocol_api/core/legacy/test_labware_offset_provider.py +++ b/api/tests/opentrons/protocol_api/core/legacy/test_labware_offset_provider.py @@ -47,7 +47,7 @@ def test_find_something( ) -> None: """It should pass along simplified labware offset info from Protocol Engine.""" decoy.when( - labware_view.find_applicable_labware_offset( + labware_view.find_applicable_labware_offset_by_legacy_location( definition_uri="some_namespace/some_load_name/123", location=LegacyLabwareOffsetLocation( slotName=DeckSlotName.SLOT_1, @@ -82,12 +82,14 @@ def test_find_nothing( subject: LabwareOffsetProvider, labware_view: LabwareView, decoy: Decoy ) -> None: """It should return a zero offset when Protocol Engine has no offset to provide.""" - decoy_call_rehearsal = labware_view.find_applicable_labware_offset( - definition_uri="some_namespace/some_load_name/123", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_1, - moduleModel=ModuleModel.TEMPERATURE_MODULE_V1, - ), + decoy_call_rehearsal = ( + labware_view.find_applicable_labware_offset_by_legacy_location( + definition_uri="some_namespace/some_load_name/123", + location=LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V1, + ), + ) ) decoy.when(decoy_call_rehearsal).then_return(None) diff --git a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py index 9f0b9576167..29c1eaa6d35 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py @@ -34,6 +34,9 @@ LabwareOffset, LabwareOffsetVector, LegacyLabwareOffsetLocation, + OnAddressableAreaOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, + OnLabwareOffsetLocationSequenceComponent, ModuleModel, ModuleDefinition, OFF_DECK_LOCATION, @@ -231,11 +234,22 @@ async def test_load_labware( version=1, ) ).then_return(minimal_labware_def) + decoy.when( + state_store.geometry.get_projected_offset_location( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + ).then_return( + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3")] + ) decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_3), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], ) ).then_return( LabwareOffset( @@ -243,6 +257,11 @@ async def test_load_labware( createdAt=datetime(year=2021, month=1, day=2), definitionUri="opentrons-test/load-name/1", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_3), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) @@ -312,11 +331,21 @@ async def test_load_labware_uses_provided_id( version=1, ) ).then_return(minimal_labware_def) - + decoy.when( + state_store.geometry.get_projected_offset_location( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + ).then_return( + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3")] + ) decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_3), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], ) ).then_return(None) @@ -354,10 +383,22 @@ async def test_load_labware_uses_loaded_labware_def( minimal_labware_def ) + decoy.when( + state_store.geometry.get_projected_offset_location( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + ).then_return( + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3")] + ) + decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_3), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], ) ).then_return(None) @@ -406,13 +447,30 @@ async def test_load_labware_on_module( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) + decoy.when( + state_store.geometry.get_projected_offset_location( + ModuleLocation(moduleId="module-id") + ) + ).then_return( + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1"), + ] + ) + decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_3, - moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1, - ), + location=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], ) ).then_return( LabwareOffset( @@ -423,6 +481,14 @@ async def test_load_labware_on_module( slotName=DeckSlotName.SLOT_3, moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1, ), + locationSequence=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) @@ -448,13 +514,23 @@ def test_find_offset_id_of_labware_on_deck_slot( subject: EquipmentHandler, ) -> None: """It should find the offset by resolving the provided location.""" + decoy.when( + state_store.geometry.get_projected_offset_location( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + ).then_return( + [ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3"), + ] + ) decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_3, - moduleModel=None, - ), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], ) ).then_return( LabwareOffset( @@ -465,6 +541,11 @@ def test_find_offset_id_of_labware_on_deck_slot( slotName=DeckSlotName.SLOT_3, moduleModel=None, ), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ) + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) @@ -490,13 +571,30 @@ def test_find_offset_id_of_labware_on_module( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) + decoy.when( + state_store.geometry.get_projected_offset_location( + ModuleLocation(moduleId="input-module-id") + ) + ).then_return( + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3"), + ] + ) + decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name/1", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_3, - moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1, - ), + location=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ), + ], ) ).then_return( LabwareOffset( @@ -507,6 +605,14 @@ def test_find_offset_id_of_labware_on_module( slotName=DeckSlotName.SLOT_3, moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1, ), + locationSequence=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.THERMOCYCLER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3" + ), + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) @@ -541,15 +647,31 @@ def test_find_offset_id_of_labware_on_labware( decoy.when(state_store.labware.get_parent_location("labware-id")).then_return( parent_location ) - + decoy.when( + state_store.geometry.get_projected_offset_location( + OnLabwareLocation(labwareId="labware-id") + ) + ).then_return( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1"), + ] + if parent_location is not OFF_DECK_LOCATION + else None + ) decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name-1/1", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_1, - moduleModel=None, - definitionUri="opentrons-test/load-name-2/1", - ), + location=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], ) ).then_return( LabwareOffset( @@ -560,6 +682,14 @@ def test_find_offset_id_of_labware_on_labware( slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/load-name-2/1", ), + locationSequence=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) @@ -594,14 +724,136 @@ def test_find_offset_id_of_labware_on_labware_on_modules( DeckSlotLocation(slotName=DeckSlotName.SLOT_1) ) + decoy.when( + state_store.geometry.get_projected_offset_location( + OnLabwareLocation(labwareId="labware-id") + ) + ).then_return( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1"), + ] + ) + decoy.when( state_store.labware.find_applicable_labware_offset( definition_uri="opentrons-test/load-name-1/1", + location=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + ) + ).then_return( + LabwareOffset( + id="labware-offset-id", + createdAt=datetime(year=2021, month=1, day=2), + definitionUri="opentrons-test/load-name/1", location=LegacyLabwareOffsetLocation( slotName=DeckSlotName.SLOT_1, moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1, definitionUri="opentrons-test/load-name-2/1", ), + locationSequence=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ) + + result = subject.find_applicable_labware_offset_id( + labware_definition_uri="opentrons-test/load-name-1/1", + labware_location=OnLabwareLocation(labwareId="labware-id"), + ) + + assert result == "labware-offset-id" + + +def test_find_offset_id_of_labware_on_labware_on_labware_modules( + decoy: Decoy, + state_store: StateStore, + subject: EquipmentHandler, +) -> None: + """It should find an offset for a labware on a labware on a module.""" + decoy.when(state_store.labware.get_definition_uri("labware-id")).then_return( + LabwareUri("opentrons-test/load-name-2/1") + ) + + decoy.when(state_store.labware.get_parent_location("labware-id")).then_return( + ModuleLocation(moduleId="labware-id-2"), + ) + + decoy.when(state_store.labware.get_definition_uri("labware-id-2")).then_return( + LabwareUri("opentrons-test/load-name-3/1") + ) + + decoy.when(state_store.labware.get_parent_location("labware-id-2")).then_return( + ModuleLocation(moduleId="module-id"), + ) + + decoy.when(state_store.modules.get_requested_model("module-id")).then_return( + ModuleModel.HEATER_SHAKER_MODULE_V1 + ) + + decoy.when(state_store.modules.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + ) + + decoy.when( + state_store.geometry.get_projected_offset_location( + OnLabwareLocation(labwareId="labware-id") + ) + ).then_return( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-3/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1"), + ] + ) + + decoy.when( + state_store.labware.find_applicable_labware_offset( + definition_uri="opentrons-test/load-name-1/1", + location=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-3/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], ) ).then_return( LabwareOffset( @@ -613,6 +865,20 @@ def test_find_offset_id_of_labware_on_labware_on_modules( moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1, definitionUri="opentrons-test/load-name-2/1", ), + locationSequence=[ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-2/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/load-name-3/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.HEATER_SHAKER_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) ) 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 bf82c17c6bc..aa644aa9430 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -64,6 +64,9 @@ ProbedHeightInfo, LoadedVolumeInfo, WellLiquidInfo, + OnAddressableAreaOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, + OnLabwareOffsetLocationSequenceComponent, ) from opentrons.protocol_engine.commands import ( CommandStatus, @@ -3017,10 +3020,9 @@ def test_get_offset_location_deck_slot( ) labware_store.handle_action(action) offset_location = subject.get_offset_location("labware-id-1") - assert offset_location is not None - assert offset_location.slotName == DeckSlotName.SLOT_C2 - assert offset_location.definitionUri is None - assert offset_location.moduleModel is None + assert offset_location == [ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="C2") + ] @pytest.mark.parametrize("use_mocks", [False]) @@ -3037,7 +3039,7 @@ def test_get_offset_location_module( command=LoadModule( params=LoadModuleParams( location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A3), - model=ModuleModel.TEMPERATURE_MODULE_V1, + model=ModuleModel.TEMPERATURE_MODULE_V2, ), id="load-module-1", createdAt=datetime.now(), @@ -3082,10 +3084,14 @@ def test_get_offset_location_module( module_store.handle_action(load_module) labware_store.handle_action(load_labware) offset_location = subject.get_offset_location("labware-id-1") - assert offset_location is not None - assert offset_location.slotName == DeckSlotName.SLOT_A3 - assert offset_location.definitionUri is None - assert offset_location.moduleModel == ModuleModel.TEMPERATURE_MODULE_V1 + assert offset_location == [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2A3" + ), + ] @pytest.mark.parametrize("use_mocks", [False]) @@ -3103,8 +3109,8 @@ def test_get_offset_location_module_with_adapter( load_module = SucceedCommandAction( command=LoadModule( params=LoadModuleParams( - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A2), - model=ModuleModel.TEMPERATURE_MODULE_V1, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A3), + model=ModuleModel.TEMPERATURE_MODULE_V2, ), id="load-module-1", createdAt=datetime.now(), @@ -3177,12 +3183,17 @@ def test_get_offset_location_module_with_adapter( labware_store.handle_action(load_adapter) labware_store.handle_action(load_labware) offset_location = subject.get_offset_location("labware-id-1") - assert offset_location is not None - assert offset_location.slotName == DeckSlotName.SLOT_A2 - assert offset_location.definitionUri == labware_view.get_uri_from_definition( - nice_adapter_definition - ) - assert offset_location.moduleModel == ModuleModel.TEMPERATURE_MODULE_V1 + assert offset_location == [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri=labware_view.get_uri_from_definition(nice_adapter_definition) + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2A3" + ), + ] @pytest.mark.parametrize("use_mocks", [False]) diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_store_old.py b/api/tests/opentrons/protocol_engine/state/test_labware_store_old.py index d7ea6655881..75e3aeb7339 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_store_old.py @@ -4,6 +4,7 @@ longer helpful. Try to add new tests to test_labware_state.py, where they can be tested together, treating LabwareState as a private implementation detail. """ + from typing import Optional from opentrons.protocol_engine.state import update_types import pytest @@ -17,9 +18,10 @@ from opentrons.protocol_engine.types import ( LabwareOffset, - LabwareOffsetCreate, + LabwareOffsetCreateInternal, LabwareOffsetVector, LegacyLabwareOffsetLocation, + OnAddressableAreaOffsetLocationSequenceComponent, DeckSlotLocation, LoadedLabware, OFF_DECK_LOCATION, @@ -64,9 +66,12 @@ def test_handles_add_labware_offset( subject: LabwareStore, ) -> None: """It should add the labware offset to the state and add the ID.""" - request = LabwareOffsetCreate( + request = LabwareOffsetCreateInternal( definitionUri="offset-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) @@ -75,6 +80,9 @@ def test_handles_add_labware_offset( createdAt=datetime(year=2021, month=1, day=2), definitionUri="offset-definition-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) @@ -99,9 +107,12 @@ def test_handles_load_labware( offset_id: Optional[str], ) -> None: """It should add the labware data to the state.""" - offset_request = LabwareOffsetCreate( + offset_request = LabwareOffsetCreateInternal( definitionUri="offset-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) @@ -180,9 +191,12 @@ def test_handles_reload_labware( == expected_definition_uri ) - offset_request = LabwareOffsetCreate( + offset_request = LabwareOffsetCreateInternal( definitionUri="offset-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) subject.handle_action( @@ -242,9 +256,12 @@ def test_handles_move_labware( ) -> None: """It should update labware state with new location & offset.""" comment_command = create_comment_command() - offset_request = LabwareOffsetCreate( + offset_request = LabwareOffsetCreateInternal( definitionUri="offset-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) subject.handle_action( @@ -297,9 +314,12 @@ def test_handles_move_labware_off_deck( ) -> None: """It should update labware state with new location & offset.""" comment_command = create_comment_command() - offset_request = LabwareOffsetCreate( + offset_request = LabwareOffsetCreateInternal( definitionUri="offset-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=2, z=3), ) subject.handle_action( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view_old.py b/api/tests/opentrons/protocol_engine/state/test_labware_view_old.py index 95f8bea6972..699893b47b4 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view_old.py @@ -4,6 +4,7 @@ longer helpful. Try to add new tests to test_labware_state.py, where they can be tested together, treating LabwareState as a private implementation detail. """ + import pytest from datetime import datetime from typing import Dict, Optional, cast, ContextManager, Any, Union, NamedTuple, List @@ -44,6 +45,8 @@ OFF_DECK_LOCATION, OverlapOffset, LabwareMovementOffsetData, + OnAddressableAreaOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, ) from opentrons.protocol_engine.state._move_types import EdgePathType from opentrons.protocol_engine.state.labware import ( @@ -839,6 +842,9 @@ def test_get_labware_offset_vector() -> None: createdAt=datetime(year=2021, month=1, day=2), definitionUri="some-labware-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=offset_vector, ) @@ -867,6 +873,9 @@ def test_get_labware_offset() -> None: createdAt=datetime(year=2021, month=1, day=1), definitionUri="uri-a", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=1, z=1), ) @@ -875,6 +884,9 @@ def test_get_labware_offset() -> None: createdAt=datetime(year=2022, month=2, day=2), definitionUri="uri-b", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="2") + ], vector=LabwareOffsetVector(x=2, y=2, z=2), ) @@ -895,6 +907,9 @@ def test_get_labware_offsets() -> None: createdAt=datetime(year=2021, month=1, day=1), definitionUri="uri-a", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=1, z=1), ) @@ -903,6 +918,9 @@ def test_get_labware_offsets() -> None: createdAt=datetime(year=2022, month=2, day=2), definitionUri="uri-b", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="2") + ], vector=LabwareOffsetVector(x=2, y=2, z=2), ) @@ -927,6 +945,9 @@ def test_find_applicable_labware_offset() -> None: createdAt=datetime(year=2021, month=1, day=1), definitionUri="definition-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=1, y=1, z=1), ) @@ -936,6 +957,9 @@ def test_find_applicable_labware_offset() -> None: createdAt=datetime(year=2022, month=2, day=2), definitionUri="definition-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], vector=LabwareOffsetVector(x=2, y=2, z=2), ) @@ -947,6 +971,12 @@ def test_find_applicable_labware_offset() -> None: slotName=DeckSlotName.SLOT_1, moduleModel=ModuleModel.TEMPERATURE_MODULE_V1, ), + locationSequence=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1"), + ], vector=LabwareOffsetVector(x=3, y=3, z=3), ) @@ -959,7 +989,11 @@ def test_find_applicable_labware_offset() -> None: assert ( subject.find_applicable_labware_offset( definition_uri="definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ) + ], ) == offset_2 ) @@ -967,10 +1001,14 @@ def test_find_applicable_labware_offset() -> None: assert ( subject.find_applicable_labware_offset( definition_uri="on-module-definition-uri", - location=LegacyLabwareOffsetLocation( - slotName=DeckSlotName.SLOT_1, - moduleModel=ModuleModel.TEMPERATURE_MODULE_V1, - ), + location=[ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V1 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], ) == offset_3 ) @@ -979,7 +1017,11 @@ def test_find_applicable_labware_offset() -> None: assert ( subject.find_applicable_labware_offset( definition_uri="different-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ) + ], ) is None ) @@ -988,7 +1030,11 @@ def test_find_applicable_labware_offset() -> None: assert ( subject.find_applicable_labware_offset( definition_uri="different-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), + location=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="2" + ) + ], ) is None ) diff --git a/api/tests/opentrons/protocol_engine/test_labware_offset_standardization.py b/api/tests/opentrons/protocol_engine/test_labware_offset_standardization.py new file mode 100644 index 00000000000..f78885a8428 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/test_labware_offset_standardization.py @@ -0,0 +1,725 @@ +"""Tests for `labware_offset_standardization`.""" + +from functools import lru_cache +import pytest + +from opentrons_shared_data.robot.types import RobotType +from opentrons_shared_data.deck import load +from opentrons_shared_data.deck.types import DeckDefinitionV5 +from opentrons.types import DeckSlotName +from opentrons.protocol_engine import labware_offset_standardization as subject +from opentrons.protocol_engine.types import ( + LabwareOffsetCreate, + LegacyLabwareOffsetCreate, + LegacyLabwareOffsetLocation, + OnLabwareOffsetLocationSequenceComponent, + OnModuleOffsetLocationSequenceComponent, + OnAddressableAreaOffsetLocationSequenceComponent, + ModuleModel, + LabwareOffsetVector, + LabwareOffsetLocationSequence, + LabwareOffsetCreateInternal, +) + + +@lru_cache +def load_from_robot_type(robot_type: RobotType) -> DeckDefinitionV5: + """Get a deck from robot type.""" + if robot_type == "OT-3 Standard": + return load("ot3_standard") + else: + return load("ot2_standard") + + +@pytest.mark.parametrize( + ("location", "robot_type", "expected_modern_location", "expected_legacy_location"), + [ + # Directly on a slot + pytest.param( + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), + "OT-2 Standard", + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="5")], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), + id="direct-slot-ot2-native", + ), + pytest.param( + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), + "OT-3 Standard", + [ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="C2" + ) + ], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2), + id="direct-slot-flex-ot2", + ), + pytest.param( + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2), + "OT-2 Standard", + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="5")], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), + id="direct-slot-ot2-flex", + ), + pytest.param( + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2), + "OT-3 Standard", + [ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="C2" + ) + ], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2), + id="direct-slot-flex-native", + ), + # On a module with no adapter + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-3 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-flex-native", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-3 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2 + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-flex-ot2", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-2 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-ot2-flex", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-2 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-ot2-native", + ), + # On a labware (or stack...) on a slot + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, definitionUri="opentrons-test/foo/1" + ), + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="D1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-flex-native", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, definitionUri="opentrons-test/foo/1" + ), + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-ot2-flex", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/foo/1" + ), + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="D1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-flex-ot2", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/foo/1" + ), + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-ot2-native", + ), + # On an adapter on a module + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-module-flex-native", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-module-ot2-flex", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-module-flex-ot2", + ), + pytest.param( + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-module-ot2-native", + ), + ], +) +def test_standardize_legacy_labware_offset( + location: LegacyLabwareOffsetLocation, + robot_type: RobotType, + expected_modern_location: LabwareOffsetLocationSequence, + expected_legacy_location: LegacyLabwareOffsetLocation, +) -> None: + """It should convert deck slots in `LegacyLabwareOffsetCreate`s and go to the new format.""" + deck_def = load_from_robot_type(robot_type) + original = LegacyLabwareOffsetCreate( + definitionUri="opentrons-test/foo/1", + location=location, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + expected = LabwareOffsetCreateInternal( + definitionUri="opentrons-test/foo/1", + legacyLocation=expected_legacy_location, + locationSequence=expected_modern_location, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + assert ( + subject.standardize_labware_offset_create(original, robot_type, deck_def) + == expected + ) + + +@pytest.mark.parametrize( + ("location", "robot_type", "expected_modern_location", "expected_legacy_location"), + [ + # Directly on a slot + pytest.param( + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="5")], + "OT-2 Standard", + [OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="5")], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), + id="slot-direct-ot2", + ), + pytest.param( + [ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="C2" + ) + ], + "OT-3 Standard", + [ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="C2" + ) + ], + LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_C2), + id="slot-direct-flex", + ), + # On a module with no adapter + pytest.param( + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + "OT-3 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-slot-flex", + ), + pytest.param( + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + "OT-2 Standard", + [ + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="module-slot-ot2", + ), + # On a labware on a slot + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="D1" + ), + ], + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="D1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-flex", + ), + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, definitionUri="opentrons-test/foo/1" + ), + id="labware-slot-ot2", + ), + # On an adapter on a module + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="temperatureModuleV2D1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_D1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-module-flex", + ), + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-slot-ot2", + ), + # On a stack of labware + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="A3", + ), + ], + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="A3", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_A3, + definitionUri="opentrons-test/foo/1", + ), + id="labware-stack-flex", + ), + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="2", + ), + ], + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="2", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_2, + definitionUri="opentrons-test/foo/1", + ), + id="labware-stack-ot2", + ), + # On a stack of labware on a module + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3", + ), + ], + "OT-2 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="3", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_3, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-stack-module-ot2", + ), + pytest.param( + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="A1", + ), + ], + "OT-3 Standard", + [ + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/foo/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/bar/1" + ), + OnLabwareOffsetLocationSequenceComponent( + labwareUri="opentrons-test/baz/1" + ), + OnModuleOffsetLocationSequenceComponent( + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="A1", + ), + ], + LegacyLabwareOffsetLocation( + slotName=DeckSlotName.SLOT_A1, + definitionUri="opentrons-test/foo/1", + moduleModel=ModuleModel.TEMPERATURE_MODULE_V2, + ), + id="labware-stack-module-flex", + ), + ], +) +def test_standardize_modern_labware_offset( + location: LabwareOffsetLocationSequence, + robot_type: RobotType, + expected_modern_location: LabwareOffsetLocationSequence, + expected_legacy_location: LegacyLabwareOffsetLocation, +) -> None: + """It should convert deck slots in `LabwareOffsetCreate`s and fill in the old format.""" + deck_def = load_from_robot_type(robot_type) + original = LabwareOffsetCreate( + definitionUri="opentrons-test/foo/1", + locationSequence=location, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + expected = LabwareOffsetCreateInternal( + definitionUri="opentrons-test/foo/1", + legacyLocation=expected_legacy_location, + locationSequence=expected_modern_location, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + assert ( + subject.standardize_labware_offset_create(original, robot_type, deck_def) + == expected + ) diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index e992371c66e..ed933e760d0 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -2,7 +2,7 @@ import inspect from datetime import datetime -from typing import Any +from typing import Any, cast from unittest.mock import sentinel import pytest @@ -10,6 +10,7 @@ from opentrons_shared_data.robot.types import RobotType from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from opentrons_shared_data.deck.types import DeckDefinitionV5 from opentrons.protocol_engine.actions.actions import SetErrorRecoveryPolicyAction from opentrons.protocol_engine.state.update_types import StateUpdate @@ -18,7 +19,12 @@ from opentrons.hardware_control.modules import MagDeck, TempDeck from opentrons.hardware_control.types import PauseType as HardwarePauseType -from opentrons.protocol_engine import ProtocolEngine, commands, slot_standardization +from opentrons.protocol_engine import ( + ProtocolEngine, + commands, + slot_standardization, + labware_offset_standardization, +) from opentrons.protocol_engine.errors.exceptions import ( CommandNotAllowedError, ) @@ -26,8 +32,11 @@ DeckType, LabwareOffset, LabwareOffsetCreate, + LegacyLabwareOffsetCreate, LabwareOffsetVector, LegacyLabwareOffsetLocation, + OnAddressableAreaOffsetLocationSequenceComponent, + LabwareOffsetCreateInternal, LabwareUri, ModuleDefinition, ModuleModel, @@ -138,6 +147,17 @@ def _mock_slot_standardization_module( monkeypatch.setattr(slot_standardization, name, decoy.mock(func=func)) +@pytest.fixture(autouse=True) +def _mock_labware_offset_standardization_module( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Mock out opentrons.labware_offset_standardization functions.""" + for name, func in inspect.getmembers( + labware_offset_standardization, inspect.isfunction + ): + monkeypatch.setattr(labware_offset_standardization, name, decoy.mock(func=func)) + + @pytest.fixture(autouse=True) def _mock_hash_command_params_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch @@ -1020,7 +1040,7 @@ def test_add_plugin( decoy.verify(plugin_starter.start(plugin)) -def test_add_labware_offset( +def test_add_legacy_labware_offset( decoy: Decoy, action_dispatcher: ActionDispatcher, model_utils: ModelUtils, @@ -1028,25 +1048,31 @@ def test_add_labware_offset( subject: ProtocolEngine, ) -> None: """It should have the labware offset request resolved and added to state.""" - request = LabwareOffsetCreate( + request = LegacyLabwareOffsetCreate( definitionUri="definition-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), vector=LabwareOffsetVector(x=1, y=2, z=3), ) - standardized_request = LabwareOffsetCreate( + + standardized_request = LabwareOffsetCreateInternal( definitionUri="standardized-definition-uri", - location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="2") + ], + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), vector=LabwareOffsetVector(x=2, y=3, z=4), ) id = "labware-offset-id" + created_at = datetime(year=2021, month=11, day=15) expected_result = LabwareOffset( id=id, createdAt=created_at, definitionUri=standardized_request.definitionUri, - location=standardized_request.location, + location=standardized_request.legacyLocation, + locationSequence=standardized_request.locationSequence, vector=standardized_request.vector, ) @@ -1054,8 +1080,13 @@ def test_add_labware_offset( decoy.when(state_store.config).then_return( Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) ) + decoy.when(state_store.addressable_areas.deck_definition).then_return( + cast(DeckDefinitionV5, {}) + ) decoy.when( - slot_standardization.standardize_labware_offset(request, robot_type) + labware_offset_standardization.standardize_labware_offset_create( + request, robot_type, cast(DeckDefinitionV5, {}) + ) ).then_return(standardized_request) decoy.when(model_utils.generate_id()).then_return(id) decoy.when(model_utils.get_timestamp()).then_return(created_at) @@ -1064,7 +1095,7 @@ def test_add_labware_offset( ).then_return(expected_result) result = subject.add_labware_offset( - request=LabwareOffsetCreate( + request=LegacyLabwareOffsetCreate( definitionUri="definition-uri", location=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), vector=LabwareOffsetVector(x=1, y=2, z=3), @@ -1084,6 +1115,87 @@ def test_add_labware_offset( ) +def test_add_labware_offset( + decoy: Decoy, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + state_store: StateStore, + subject: ProtocolEngine, +) -> None: + """It should have the labware offset request resolved and added to state.""" + request = LabwareOffsetCreate( + definitionUri="definition-uri", + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="1") + ], + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + + standardized_request = LabwareOffsetCreateInternal( + definitionUri="standardized-definition-uri", + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent(addressableAreaName="3") + ], + legacyLocation=LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_3), + vector=LabwareOffsetVector(x=2, y=5, z=6), + ) + + id = "labware-offset-id" + + created_at = datetime(year=2021, month=11, day=15) + + expected_result = LabwareOffset( + id=id, + createdAt=created_at, + definitionUri=standardized_request.definitionUri, + location=standardized_request.legacyLocation, + locationSequence=standardized_request.locationSequence, + vector=standardized_request.vector, + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + decoy.when(state_store.addressable_areas.deck_definition).then_return( + cast(DeckDefinitionV5, {}) + ) + decoy.when( + labware_offset_standardization.standardize_labware_offset_create( + request, robot_type, cast(DeckDefinitionV5, {}) + ) + ).then_return(standardized_request) + decoy.when(model_utils.generate_id()).then_return(id) + decoy.when(model_utils.get_timestamp()).then_return(created_at) + decoy.when( + state_store.labware.get_labware_offset(labware_offset_id=id) + ).then_return(expected_result) + + result = subject.add_labware_offset( + request=LabwareOffsetCreate( + definitionUri="definition-uri", + locationSequence=[ + OnAddressableAreaOffsetLocationSequenceComponent( + addressableAreaName="1" + ) + ], + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ) + + assert result == expected_result + + decoy.verify( + action_dispatcher.dispatch( + AddLabwareOffsetAction( + labware_offset_id=id, + created_at=created_at, + request=standardized_request, + ) + ) + ) + + def test_add_labware_definition( decoy: Decoy, action_dispatcher: ActionDispatcher, diff --git a/api/tests/opentrons/protocol_engine/test_slot_standardization.py b/api/tests/opentrons/protocol_engine/test_slot_standardization.py index ea05f0478a5..78090e16b00 100644 --- a/api/tests/opentrons/protocol_engine/test_slot_standardization.py +++ b/api/tests/opentrons/protocol_engine/test_slot_standardization.py @@ -13,8 +13,6 @@ OnLabwareLocation, LabwareLocation, LabwareMovementStrategy, - LabwareOffsetCreate, - LegacyLabwareOffsetLocation, LabwareOffsetVector, ModuleLocation, ModuleModel, @@ -22,42 +20,6 @@ ) -@pytest.mark.parametrize("module_model", [None, ModuleModel.MAGNETIC_MODULE_V1]) -@pytest.mark.parametrize( - ("slot_name", "robot_type", "expected_slot_name"), - [ - (DeckSlotName.SLOT_5, "OT-2 Standard", DeckSlotName.SLOT_5), - (DeckSlotName.SLOT_C2, "OT-2 Standard", DeckSlotName.SLOT_5), - (DeckSlotName.SLOT_5, "OT-3 Standard", DeckSlotName.SLOT_C2), - (DeckSlotName.SLOT_C2, "OT-3 Standard", DeckSlotName.SLOT_C2), - ], -) -def test_standardize_labware_offset( - module_model: ModuleModel, - slot_name: DeckSlotName, - robot_type: RobotType, - expected_slot_name: DeckSlotName, -) -> None: - """It should convert deck slots in `LabwareOffsetCreate`s.""" - original = LabwareOffsetCreate( - definitionUri="opentrons-test/foo/1", - location=LegacyLabwareOffsetLocation( - moduleModel=module_model, - slotName=slot_name, - ), - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) - expected = LabwareOffsetCreate( - definitionUri="opentrons-test/foo/1", - location=LegacyLabwareOffsetLocation( - moduleModel=module_model, - slotName=expected_slot_name, - ), - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) - assert subject.standardize_labware_offset(original, robot_type) == expected - - @pytest.mark.parametrize( ("original_location", "robot_type", "expected_location"), [ diff --git a/robot-server/robot_server/runs/router/labware_router.py b/robot-server/robot_server/runs/router/labware_router.py index f9264da51e6..78c880a2df5 100644 --- a/robot-server/robot_server/runs/router/labware_router.py +++ b/robot-server/robot_server/runs/router/labware_router.py @@ -9,7 +9,11 @@ from server_utils.fastapi_utils.light_router import LightRouter -from opentrons.protocol_engine import LabwareOffsetCreate, LabwareOffset +from opentrons.protocol_engine import ( + LabwareOffsetCreate, + LegacyLabwareOffsetCreate, + LabwareOffset, +) from robot_server.errors.error_responses import ErrorBody from robot_server.service.json_api import ( @@ -47,7 +51,7 @@ }, ) async def add_labware_offset( - request_body: RequestModel[LabwareOffsetCreate], + request_body: RequestModel[LegacyLabwareOffsetCreate | LabwareOffsetCreate], run_orchestrator_store: Annotated[ RunOrchestratorStore, Depends(get_run_orchestrator_store) ], diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index adb7cac151e..ba85db33aaa 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -33,6 +33,7 @@ from opentrons.protocol_engine import ( DeckType, LabwareOffsetCreate, + LegacyLabwareOffsetCreate, StateSummary, CommandSlice, CommandErrorSlice, @@ -192,7 +193,7 @@ async def get_default_orchestrator(self) -> RunOrchestrator: async def create( self, run_id: str, - labware_offsets: List[LabwareOffsetCreate], + labware_offsets: List[LabwareOffsetCreate | LegacyLabwareOffsetCreate], initial_error_recovery_policy: error_recovery_policy.ErrorRecoveryPolicy, deck_configuration: DeckConfigurationType, file_provider: FileProvider, @@ -408,7 +409,7 @@ def run_was_started(self) -> bool: """Get whether the run has started.""" return self.run_orchestrator.run_has_started() - def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset: + def add_labware_offset(self, request: LabwareOffsetCreate | LegacyLabwareOffsetCreate) -> LabwareOffset: """Add a new labware offset to state.""" return self.run_orchestrator.add_labware_offset(request) diff --git a/shared-data/python/opentrons_shared_data/deck/__init__.py b/shared-data/python/opentrons_shared_data/deck/__init__.py index 38607263418..a4bc6747c39 100644 --- a/shared-data/python/opentrons_shared_data/deck/__init__.py +++ b/shared-data/python/opentrons_shared_data/deck/__init__.py @@ -54,6 +54,9 @@ def load(name: str, version: "DeckSchemaVersion4") -> "DeckDefinitionV4": def load(name: str, version: "DeckSchemaVersion3") -> "DeckDefinitionV3": ... +@overload +def load(name: str) -> "DeckDefinitionV5": + ... def load(name: str, version: int = DEFAULT_DECK_DEFINITION_VERSION) -> "DeckDefinition": return json.loads( # type: ignore[no-any-return]