From a34b3bad1c7f9596a0a9d12e263bcbf77960f584 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 24 Jan 2025 13:16:30 -0500 Subject: [PATCH] refactor(api): location sequences for offsets When we load and express labware offsets, we previously used an object that had explicit named members for a labware parent; a module parent; and a deck slot. This worked as long as we don't have heterogenous labware stacking (to include labware-on-labware-on-adapter) but it's inflexible, and maybe we do want that. Instead, let's have a sequence of little models that specify a parent geometry - a labware def uri, a module model, or an addressable area name - and use that both for specifying offset locations via the HTTP API, for comparing locations internally, and for downloading locations from the HTTP API. Importantly, these offset locations are currently in the database as parts of runs, so we have to keep the old version around when expressing labware data; this is annoying, but that's life. We can calculate the equivalent old data when we load a labware offset pretty easily, and this also lets us not change the legacy protocol core. We also are going to keep the old method of specifying them around, and convert them into the new format, which is also pretty easy, to preserve backwards compatibility and roundtripping older offsets from before this data was present. --- .../protocol_api/core/engine/labware.py | 2 +- .../core/legacy/labware_offset_provider.py | 2 +- .../protocol_engine/actions/actions.py | 4 +- .../protocol_engine/errors/__init__.py | 2 + .../protocol_engine/errors/exceptions.py | 13 + .../protocol_engine/execution/equipment.py | 75 +- .../labware_offset_standardization.py | 172 +++++ .../protocol_engine/protocol_engine.py | 18 +- .../resources/deck_configuration_provider.py | 55 +- .../protocol_engine/slot_standardization.py | 19 - .../state/addressable_areas.py | 5 + .../protocol_engine/state/geometry.py | 116 ++- .../protocol_engine/state/labware.py | 30 +- .../protocol_engine/state/modules.py | 7 +- .../protocol_engine/types/__init__.py | 16 +- .../protocol_engine/types/labware.py | 38 +- .../types/labware_offset_location.py | 47 +- .../protocol_engine/types/location.py | 18 +- .../protocol_runner/run_orchestrator.py | 5 +- .../core/engine/test_labware_core.py | 11 +- .../legacy/test_labware_offset_provider.py | 16 +- .../execution/test_equipment_handler.py | 310 +++++++- .../state/test_geometry_view.py | 45 +- .../state/test_labware_store_old.py | 42 +- .../state/test_labware_view_old.py | 60 +- .../test_labware_offset_standardization.py | 725 ++++++++++++++++++ .../protocol_engine/test_protocol_engine.py | 130 +++- .../test_slot_standardization.py | 38 - .../runs/router/labware_router.py | 8 +- .../runs/run_orchestrator_store.py | 5 +- .../opentrons_shared_data/deck/__init__.py | 3 + 31 files changed, 1747 insertions(+), 290 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/labware_offset_standardization.py create mode 100644 api/tests/opentrons/protocol_engine/test_labware_offset_standardization.py 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]