From f95f9fa81857a30e6247d67fd92e236d5cc81bb8 Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Fri, 8 Dec 2023 14:29:47 -0500 Subject: [PATCH 01/11] fix(engine): better and correct errors in addressable area store and view (#14149) fixes several TODOs that were left in the Protocol Engine AddressableAreaStore and AddressableAreaView during initial development --- .../protocol_engine/commands/load_labware.py | 10 +- .../protocol_engine/commands/load_module.py | 4 + .../protocol_engine/commands/move_labware.py | 9 ++ .../commands/move_to_addressable_area.py | 10 +- .../resources/deck_configuration_provider.py | 12 ++ .../state/addressable_areas.py | 125 ++++++++++++------ .../commands/test_move_to_addressable_area.py | 6 +- .../test_deck_configuration_provider.py | 4 +- .../state/test_addressable_area_store.py | 84 ------------ .../state/test_addressable_area_view.py | 86 ++++++++++++ .../g_code_parsing/g_code_engine.py | 1 + .../deck/definitions/4/ot2_short_trash.json | 3 +- .../deck/definitions/4/ot2_standard.json | 3 +- .../deck/definitions/4/ot3_standard.json | 24 ++-- shared-data/deck/schemas/4.json | 2 +- 15 files changed, 237 insertions(+), 146 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 81323567d29..64ed68b47ba 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -111,10 +111,18 @@ async def execute(self, params: LoadLabwareParams) -> LoadLabwareResult: ) if isinstance(params.location, AddressableAreaLocation): + area_name = params.location.addressableAreaName if not fixture_validation.is_deck_slot(params.location.addressableAreaName): raise LabwareIsNotAllowedInLocationError( - f"Cannot load {params.loadName} onto addressable area {params.location.addressableAreaName}" + f"Cannot load {params.loadName} onto addressable area {area_name}" ) + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + area_name + ) + elif isinstance(params.location, DeckSlotLocation): + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) verified_location = self._state_view.geometry.ensure_location_not_occupied( params.location diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index bd89e294eba..082b88814cf 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -104,6 +104,10 @@ def __init__( async def execute(self, params: LoadModuleParams) -> LoadModuleResult: """Check that the requested module is attached and assign its identifier.""" + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) + verified_location = self._state_view.geometry.ensure_location_not_occupied( params.location ) diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 7c6b7f92cfe..d33b51eb41e 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -8,6 +8,7 @@ from opentrons.types import Point from ..types import ( LabwareLocation, + DeckSlotLocation, OnLabwareLocation, AddressableAreaLocation, LabwareMovementStrategy, @@ -115,6 +116,10 @@ async def execute( # noqa: C901 raise LabwareMovementNotAllowedError( f"Cannot move {current_labware.loadName} to addressable area {area_name}" ) + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + area_name + ) + if fixture_validation.is_gripper_waste_chute(area_name): # When dropping off labware in the waste chute, some bigger pieces # of labware (namely tipracks) can get stuck between a gripper @@ -129,6 +134,10 @@ async def execute( # noqa: C901 y=0, z=0, ) + elif isinstance(params.newLocation, DeckSlotLocation): + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.newLocation.slotName.id + ) available_new_location = self._state_view.geometry.ensure_location_not_occupied( location=params.newLocation diff --git a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py index 8ac92fde0f6..3226b63e31b 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from ..execution import MovementHandler + from ..state import StateView MoveToAddressableAreaCommandType = Literal["moveToAddressableArea"] @@ -66,13 +67,20 @@ class MoveToAddressableAreaImplementation( ): """Move to addressable area command implementation.""" - def __init__(self, movement: MovementHandler, **kwargs: object) -> None: + def __init__( + self, movement: MovementHandler, state_view: StateView, **kwargs: object + ) -> None: self._movement = movement + self._state_view = state_view async def execute( self, params: MoveToAddressableAreaParams ) -> MoveToAddressableAreaResult: """Move the requested pipette to the requested addressable area.""" + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.addressableAreaName + ) + if fixture_validation.is_staging_slot(params.addressableAreaName): raise LocationNotAccessibleByPipetteError( f"Cannot move pipette to staging slot {params.addressableAreaName}" 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 5d3829aa627..112be3663cd 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py @@ -56,6 +56,18 @@ def get_provided_addressable_area_names( ) from exception +def get_addressable_area_display_name( + addressable_area_name: str, deck_definition: DeckDefinitionV4 +) -> str: + """Get the display name for an addressable area name.""" + for addressable_area in deck_definition["locations"]["addressableAreas"]: + if addressable_area["id"] == addressable_area_name: + return addressable_area["displayName"] + raise AddressableAreaDoesNotExistError( + f"Could not find addressable area with name {addressable_area_name}" + ) + + def get_potential_cutout_fixtures( addressable_area_name: str, deck_definition: DeckDefinitionV4 ) -> Tuple[str, Set[PotentialCutoutFixture]]: diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 88da52d6c71..7d03e556d92 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -84,11 +84,11 @@ class AddressableAreaState: _FLEX_ORDERED_STAGING_SLOTS = ["D4", "C4", "B4", "A4"] -def _get_conflicting_addressable_areas( +def _get_conflicting_addressable_areas_error_string( potential_cutout_fixtures: Set[PotentialCutoutFixture], - loaded_addressable_areas: Set[str], + loaded_addressable_areas: Dict[str, AddressableArea], deck_definition: DeckDefinitionV4, -) -> Set[str]: +) -> str: loaded_areas_on_cutout = set() for fixture in potential_cutout_fixtures: loaded_areas_on_cutout.update( @@ -99,7 +99,10 @@ def _get_conflicting_addressable_areas( ) ) loaded_areas_on_cutout.intersection_update(loaded_addressable_areas) - return loaded_areas_on_cutout + display_names = { + loaded_addressable_areas[area].display_name for area in loaded_areas_on_cutout + } + return ", ".join(display_names) # This is a temporary shim while Protocol Engine's conflict-checking code @@ -209,10 +212,6 @@ def _get_addressable_areas_from_deck_configuration( deck_config: DeckConfigurationType, deck_definition: DeckDefinitionV4 ) -> Dict[str, AddressableArea]: """Return all addressable areas provided by the given deck configuration.""" - # TODO uncomment once execute is hooked up with this properly - # assert ( - # len(deck_config) == 12 - # ), f"{len(deck_config)} cutout fixture ids provided." addressable_areas = [] for cutout_id, cutout_fixture_id in deck_config: provided_addressable_areas = ( @@ -246,10 +245,6 @@ def _check_location_is_addressable_area( addressable_area_name = location if addressable_area_name not in self._state.loaded_addressable_areas_by_name: - # TODO Validate that during an actual run, the deck configuration provides the requested - # addressable area. If it does not, MoveToAddressableArea.execute() needs to raise; - # this store class cannot raise because Protocol Engine stores are not allowed to. - cutout_id = self._validate_addressable_area_for_simulation( addressable_area_name ) @@ -286,23 +281,10 @@ def _validate_addressable_area_for_simulation( existing_potential_fixtures = ( self._state.potential_cutout_fixtures_by_cutout_id[cutout_id] ) - # See if there's any common cutout fixture that supplies existing addressable areas and the one being loaded + # Get common cutout fixture that supplies existing addressable areas and the one being loaded remaining_fixtures = existing_potential_fixtures.intersection( set(potential_fixtures) ) - if not remaining_fixtures: - loaded_areas_on_cutout = _get_conflicting_addressable_areas( - existing_potential_fixtures, - set(self.state.loaded_addressable_areas_by_name), - self._state.deck_definition, - ) - # FIXME(mm, 2023-12-01): This needs to be raised from within - # MoveToAddressableAreaImplementation.execute(). Protocol Engine stores are not - # allowed to raise. - raise IncompatibleAddressableAreaError( - f"Cannot load {addressable_area_name}, not compatible with one or more of" - f" the following areas: {loaded_areas_on_cutout}" - ) self._state.potential_cutout_fixtures_by_cutout_id[ cutout_id @@ -365,6 +347,33 @@ def _get_loaded_addressable_area( f"{addressable_area_name} not provided by deck configuration." ) + def _check_if_area_is_compatible_with_potential_fixtures( + self, + area_name: str, + cutout_id: str, + potential_fixtures: Set[PotentialCutoutFixture], + ) -> None: + if cutout_id in self._state.potential_cutout_fixtures_by_cutout_id: + if not self._state.potential_cutout_fixtures_by_cutout_id[ + cutout_id + ].intersection(potential_fixtures): + loaded_areas_on_cutout = ( + _get_conflicting_addressable_areas_error_string( + self._state.potential_cutout_fixtures_by_cutout_id[cutout_id], + self._state.loaded_addressable_areas_by_name, + self.state.deck_definition, + ) + ) + area_display_name = ( + deck_configuration_provider.get_addressable_area_display_name( + area_name, self.state.deck_definition + ) + ) + raise IncompatibleAddressableAreaError( + f"Cannot use {area_display_name}, not compatible with one or more of" + f" the following fixtures: {loaded_areas_on_cutout}" + ) + def _get_addressable_area_from_deck_data( self, addressable_area_name: str ) -> AddressableArea: @@ -384,19 +393,9 @@ def _get_addressable_area_from_deck_data( addressable_area_name, self._state.deck_definition ) - if cutout_id in self._state.potential_cutout_fixtures_by_cutout_id: - if not self._state.potential_cutout_fixtures_by_cutout_id[ - cutout_id - ].intersection(potential_fixtures): - loaded_areas_on_cutout = _get_conflicting_addressable_areas( - self._state.potential_cutout_fixtures_by_cutout_id[cutout_id], - set(self._state.loaded_addressable_areas_by_name), - self.state.deck_definition, - ) - raise IncompatibleAddressableAreaError( - f"Cannot load {addressable_area_name}, not compatible with one or more of" - f" the following areas: {loaded_areas_on_cutout}" - ) + self._check_if_area_is_compatible_with_potential_fixtures( + addressable_area_name, cutout_id, potential_fixtures + ) cutout_position = deck_configuration_provider.get_cutout_position( cutout_id, self._state.deck_definition @@ -417,8 +416,16 @@ def get_addressable_area_base_slot( return addressable_area.base_slot def get_addressable_area_position(self, addressable_area_name: str) -> Point: - """Get the position of an addressable area.""" - # TODO This should be the regular `get_addressable_area` once Robot Server deck config and tests is hooked up + """Get the position of an addressable area. + + This does not require the addressable area to be in the deck configuration. + This is primarily used to support legacy fixed trash labware without + modifying the deck layout to remove the similar, but functionally different, + trashBinAdapter cutout fixture. + + Besides that instance, for movement purposes, this should only be called for + areas that have been pre-validated, otherwise there could be the risk of collision. + """ addressable_area = self._get_addressable_area_from_deck_data( addressable_area_name ) @@ -457,7 +464,10 @@ def get_fixture_height(self, cutout_fixture_name: str) -> float: return cutout_fixture["height"] def get_slot_definition(self, slot_id: str) -> SlotDefV3: - """Get the definition of a slot in the deck.""" + """Get the definition of a slot in the deck. + + This does not require that the slot exist in deck configuration. + """ try: addressable_area = self._get_addressable_area_from_deck_data(slot_id) except AddressableAreaDoesNotExistError: @@ -495,3 +505,34 @@ def get_staging_slot_definitions(self) -> Dict[str, SlotDefV3]: } else: return {} + + def raise_if_area_not_in_deck_configuration( + self, addressable_area_name: str + ) -> None: + """Raise error if an addressable area is not compatible with or in the deck configuration. + + For simulated runs/analysis, this will raise if the given addressable area is not compatible with other + previously referenced addressable areas, for example if a movable trash in A1 is in state, referencing the + deck slot A1 will raise since those two can't exist in any deck configuration combination. + + For an on robot run, it will check if it is in the robot's deck configuration, if not it will raise an error. + """ + if self._state.use_simulated_deck_config: + ( + cutout_id, + potential_fixtures, + ) = deck_configuration_provider.get_potential_cutout_fixtures( + addressable_area_name, self._state.deck_definition + ) + + self._check_if_area_is_compatible_with_potential_fixtures( + addressable_area_name, cutout_id, potential_fixtures + ) + else: + if ( + addressable_area_name + not in self._state.loaded_addressable_areas_by_name + ): + raise AreaNotInDeckConfigurationError( + f"{addressable_area_name} not provided by deck configuration." + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py index 5b2db28b501..b18e9ba7c97 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py @@ -3,6 +3,7 @@ from opentrons.protocol_engine import DeckPoint, AddressableOffsetVector from opentrons.protocol_engine.execution import MovementHandler +from opentrons.protocol_engine.state import StateView from opentrons.types import Point from opentrons.protocol_engine.commands.move_to_addressable_area import ( @@ -14,10 +15,13 @@ async def test_move_to_addressable_area_implementation( decoy: Decoy, + state_view: StateView, movement: MovementHandler, ) -> None: """A MoveToAddressableArea command should have an execution implementation.""" - subject = MoveToAddressableAreaImplementation(movement=movement) + subject = MoveToAddressableAreaImplementation( + movement=movement, state_view=state_view + ) data = MoveToAddressableAreaParams( pipetteId="abc", diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py index 8550a6203be..ec5ea38376a 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py @@ -302,7 +302,7 @@ def test_get_potential_cutout_fixtures_raises( area_name="movableTrashB3", area_type=AreaType.MOVABLE_TRASH, base_slot=DeckSlotName.SLOT_A1, - display_name="Trash Bin", + display_name="Trash Bin in B3", bounding_box=Dimensions(x=246.5, y=91.5, z=40), position=AddressableOffsetVector(x=-16, y=-0.75, z=3), compatible_module_types=[], @@ -315,7 +315,7 @@ def test_get_potential_cutout_fixtures_raises( area_name="gripperWasteChute", area_type=AreaType.WASTE_CHUTE, base_slot=DeckSlotName.SLOT_A1, - display_name="Gripper Waste Chute", + display_name="Waste Chute", bounding_box=Dimensions(x=0, y=0, z=0), position=AddressableOffsetVector(x=65, y=31, z=139.5), compatible_module_types=[], diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py index 7592b551087..a3e2d66844d 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py @@ -8,10 +8,6 @@ from opentrons.protocol_engine.commands import Command from opentrons.protocol_engine.actions import UpdateCommandAction -from opentrons.protocol_engine.errors import ( - # AreaNotInDeckConfigurationError, - IncompatibleAddressableAreaError, -) from opentrons.protocol_engine.state import Config from opentrons.protocol_engine.state.addressable_areas import ( AddressableAreaStore, @@ -184,51 +180,6 @@ def test_addressable_area_referencing_commands_load_on_simulated_deck( assert expected_area in simulated_subject.state.loaded_addressable_areas_by_name -@pytest.mark.parametrize( - "command", - ( - create_load_labware_command( - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), - labware_id="test-labware-id", - definition=LabwareDefinition.construct( # type: ignore[call-arg] - parameters=Parameters.construct(loadName="blah"), # type: ignore[call-arg] - namespace="bleh", - version=123, - ), - offset_id="offset-id", - display_name="display-name", - ), - create_load_module_command( - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), - module_id="test-module-id", - model=ModuleModel.TEMPERATURE_MODULE_V2, - ), - create_move_labware_command( - new_location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), - strategy=LabwareMovementStrategy.USING_GRIPPER, - ), - ), -) -def test_handles_command_simulated_raises( - command: Command, - simulated_subject: AddressableAreaStore, -) -> None: - """It should raise when two incompatible areas are referenced.""" - initial_command = create_move_labware_command( - new_location=AddressableAreaLocation(addressableAreaName="gripperWasteChute"), - strategy=LabwareMovementStrategy.USING_GRIPPER, - ) - - simulated_subject.handle_action( - UpdateCommandAction(private_result=None, command=initial_command) - ) - - with pytest.raises(IncompatibleAddressableAreaError): - simulated_subject.handle_action( - UpdateCommandAction(private_result=None, command=command) - ) - - @pytest.mark.parametrize( ("command", "expected_area"), ( @@ -292,38 +243,3 @@ def test_addressable_area_referencing_commands_load( """It should check that the addressable area is in the deck config.""" subject.handle_action(UpdateCommandAction(private_result=None, command=command)) assert expected_area in subject.state.loaded_addressable_areas_by_name - - -# TODO Uncomment this out once this check is back in -# @pytest.mark.parametrize( -# "command", -# ( -# create_load_labware_command( -# location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), -# labware_id="test-labware-id", -# definition=LabwareDefinition.construct( # type: ignore[call-arg] -# parameters=Parameters.construct(loadName="blah"), # type: ignore[call-arg] -# namespace="bleh", -# version=123, -# ), -# offset_id="offset-id", -# display_name="display-name", -# ), -# create_load_module_command( -# location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), -# module_id="test-module-id", -# model=ModuleModel.TEMPERATURE_MODULE_V2, -# ), -# create_move_labware_command( -# new_location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D3), -# strategy=LabwareMovementStrategy.USING_GRIPPER, -# ), -# ), -# ) -# def test_handles_load_labware_raises( -# command: Command, -# subject: AddressableAreaStore, -# ) -> None: -# """It should raise when referencing an addressable area not in the deck config.""" -# with pytest.raises(AreaNotInDeckConfigurationError): -# subject.handle_action(UpdateCommandAction(private_result=None, command=command)) diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py index 738acf9a2f2..34ddcaa37fa 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py @@ -377,3 +377,89 @@ def test_get_slot_definition_raises_with_bad_slot_name(decoy: Decoy) -> None: with pytest.raises(SlotDoesNotExistError): subject.get_slot_definition("foo") + + +def test_raise_if_area_not_in_deck_configuration_on_robot(decoy: Decoy) -> None: + """It should raise if the requested addressable area name is not loaded in state.""" + subject = get_addressable_area_view( + loaded_addressable_areas_by_name={"real": decoy.mock(cls=AddressableArea)} + ) + + subject.raise_if_area_not_in_deck_configuration("real") + + with pytest.raises(AreaNotInDeckConfigurationError): + subject.raise_if_area_not_in_deck_configuration("fake") + + +def test_raise_if_area_not_in_deck_configuration_simulated_config(decoy: Decoy) -> None: + """It should raise if the requested addressable area name is not loaded in state.""" + subject = get_addressable_area_view( + use_simulated_deck_config=True, + potential_cutout_fixtures_by_cutout_id={ + "waluigi": { + PotentialCutoutFixture( + cutout_id="fire flower", + cutout_fixture_id="1up", + provided_addressable_areas=frozenset(), + ) + }, + "wario": { + PotentialCutoutFixture( + cutout_id="mushroom", + cutout_fixture_id="star", + provided_addressable_areas=frozenset(), + ) + }, + }, + ) + + decoy.when( + deck_configuration_provider.get_potential_cutout_fixtures( + "mario", subject.state.deck_definition + ) + ).then_return( + ( + "wario", + { + PotentialCutoutFixture( + cutout_id="mushroom", + cutout_fixture_id="star", + provided_addressable_areas=frozenset(), + ) + }, + ) + ) + + subject.raise_if_area_not_in_deck_configuration("mario") + + decoy.when( + deck_configuration_provider.get_potential_cutout_fixtures( + "luigi", subject.state.deck_definition + ) + ).then_return( + ( + "waluigi", + { + PotentialCutoutFixture( + cutout_id="mushroom", + cutout_fixture_id="star", + provided_addressable_areas=frozenset(), + ) + }, + ) + ) + + decoy.when( + deck_configuration_provider.get_provided_addressable_area_names( + "1up", "fire flower", subject.state.deck_definition + ) + ).then_return([]) + + decoy.when( + deck_configuration_provider.get_addressable_area_display_name( + "luigi", subject.state.deck_definition + ) + ).then_return("super luigi") + + with pytest.raises(IncompatibleAddressableAreaError): + subject.raise_if_area_not_in_deck_configuration("luigi") diff --git a/g-code-testing/g_code_parsing/g_code_engine.py b/g-code-testing/g_code_parsing/g_code_engine.py index 434ad458680..29e5b046e7f 100644 --- a/g-code-testing/g_code_parsing/g_code_engine.py +++ b/g-code-testing/g_code_parsing/g_code_engine.py @@ -172,6 +172,7 @@ async def run_protocol( deck_type=DeckType( deck_type.for_simulation(robot_type=robot_type) ), + use_simulated_deck_config=True, ), load_fixed_trash=deck_type.should_load_fixed_trash(config), ), diff --git a/shared-data/deck/definitions/4/ot2_short_trash.json b/shared-data/deck/definitions/4/ot2_short_trash.json index 7a313aebb56..0810bbb3eac 100644 --- a/shared-data/deck/definitions/4/ot2_short_trash.json +++ b/shared-data/deck/definitions/4/ot2_short_trash.json @@ -372,7 +372,8 @@ "cutout8", "cutout9", "cutout10", - "cutout11" + "cutout11", + "cutout12" ], "displayName": "Standard Slot", "providesAddressableAreas": { diff --git a/shared-data/deck/definitions/4/ot2_standard.json b/shared-data/deck/definitions/4/ot2_standard.json index ebfefa5f57e..26b591bf04a 100644 --- a/shared-data/deck/definitions/4/ot2_standard.json +++ b/shared-data/deck/definitions/4/ot2_standard.json @@ -372,7 +372,8 @@ "cutout8", "cutout9", "cutout10", - "cutout11" + "cutout11", + "cutout12" ], "displayName": "Standard Slot", "providesAddressableAreas": { diff --git a/shared-data/deck/definitions/4/ot3_standard.json b/shared-data/deck/definitions/4/ot3_standard.json index bff80b838cb..216e19db5c6 100644 --- a/shared-data/deck/definitions/4/ot3_standard.json +++ b/shared-data/deck/definitions/4/ot3_standard.json @@ -262,7 +262,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in D1", "ableToDropTips": true }, { @@ -274,7 +274,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in C1", "ableToDropTips": true }, { @@ -286,7 +286,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in B1", "ableToDropTips": true }, { @@ -298,7 +298,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in A1", "ableToDropTips": true }, { @@ -310,7 +310,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in D3", "ableToDropTips": true }, { @@ -322,7 +322,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in C3", "ableToDropTips": true }, { @@ -334,7 +334,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in B3", "ableToDropTips": true }, { @@ -346,7 +346,7 @@ "yDimension": 91.5, "zDimension": 40 }, - "displayName": "Trash Bin", + "displayName": "Trash Bin in A3", "ableToDropTips": true }, { @@ -358,7 +358,7 @@ "yDimension": 0, "zDimension": 0 }, - "displayName": "1 Channel Waste Chute", + "displayName": "Waste Chute", "ableToDropTips": true }, { @@ -370,7 +370,7 @@ "yDimension": 63, "zDimension": 0 }, - "displayName": "8 Channel Waste Chute", + "displayName": "Waste Chute", "ableToDropTips": true }, { @@ -382,7 +382,7 @@ "yDimension": 63, "zDimension": 0 }, - "displayName": "96 Channel Waste Chute", + "displayName": "Waste Chute", "ableToDropTips": true }, { @@ -394,7 +394,7 @@ "yDimension": 0, "zDimension": 0 }, - "displayName": "Gripper Waste Chute", + "displayName": "Waste Chute", "ableToDropLabware": true } ], diff --git a/shared-data/deck/schemas/4.json b/shared-data/deck/schemas/4.json index eb0cb3b81ed..719ce41f0c8 100644 --- a/shared-data/deck/schemas/4.json +++ b/shared-data/deck/schemas/4.json @@ -157,7 +157,7 @@ "$ref": "#/definitions/boundingBox" }, "displayName": { - "description": "A human-readable nickname for this area e.g. \"Slot A1\" or \"Movable Trash\"", + "description": "A human-readable nickname for this area e.g. \"Slot A1\" or \"Trash Bin in A1\"", "type": "string" }, "compatibleModuleTypes": { From bfd14200710ef361fcc102b7c8518307f2294d21 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Fri, 8 Dec 2023 15:10:58 -0500 Subject: [PATCH 02/11] docs(api): more partial tip pickup docstrings (#14132) --- .../protocol_api/instrument_context.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 7651cd26950..c411f809e7f 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1636,7 +1636,11 @@ def channels(self) -> int: @property # type: ignore @requires_version(2, 16) def active_channels(self) -> int: - """The number of channels configured for active use using configure_nozzle_layout().""" + """The number of channels the pipette will use to pick up tips. + + By default, all channels on the pipette. Use :py:meth:`.configure_nozzle_layout` + to set the pipette to use fewer channels. + """ return self._core.get_active_channels() @property # type: ignore @@ -1798,24 +1802,35 @@ def configure_nozzle_layout( .. note:: When picking up fewer than 96 tips at once, the tip rack *must not* be - placed in a tip rack adapter in the deck. If you try to perform partial tip - pickup on a tip rack that is in an adapter, the API will raise an error. + placed in a tip rack adapter in the deck. If you try to pick up fewer than 96 + tips from a tip rack that is in an adapter, the API will raise an error. :param style: The shape of the nozzle layout. - ``COLUMN`` sets the pipette to use 8 nozzles, aligned from front to back with respect to the deck. This corresponds to a column of wells on labware. - - ``ALL`` resets the pipette to use all of its nozzles. Calling ``configure_nozzle_layout`` with no arguments also resets the pipette. + - ``ALL`` resets the pipette to use all of its nozzles. Calling + ``configure_nozzle_layout`` with no arguments also resets the pipette. :type style: ``NozzleLayout`` or ``None`` :param start: The nozzle at the back left of the layout, which the robot uses to determine how it will move to different locations on the deck. The string - should be of the same format used when identifying wells by name. Use - ``"A1"`` to have the pipette use its leftmost nozzles to pick up tips - *left-to-right* from a tip rack. Use ``"A12"`` to have the pipette use its - rightmost nozzles to pick up tips *right-to-left* from a tip rack. + should be of the same format used when identifying wells by name. + Required unless setting ``style=ALL``. + + .. note:: + When using the ``COLUMN`` layout, the only fully supported value is + ``start="A12"``. You can use ``start="A1"``, but this will disable tip + tracking and you will have to specify the ``location`` every time you + call :py:meth:`.pick_up_tip`, such that the pipette picks up columns of + tips *from right to left* on the tip rack. + :type start: str or ``None`` - :param tip_racks: List of tip racks to use during this configuration. + :param tip_racks: Behaves the same as setting the ``tip_racks`` parameter of + :py:meth:`.load_instrument`. If not specified, the new configuration resets + :py:obj:`.InstrumentContext.tip_racks` and you must specify the location + every time you call :py:meth:`~.InstrumentContext.pick_up_tip`. + :type tip_racks: List[:py:class:`.Labware`] """ # TODO: add the following back into the docstring when QUADRANT is supported # From 8869fa11fa111fb705502a84c7a6f8c9d2cc324e Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:10:29 -0500 Subject: [PATCH 03/11] fix(app): moveLabware command text support for waste chute (#14153) closes RQA-2037 --- .../CommandText/MoveLabwareCommandText.tsx | 20 ++++++--- .../__tests__/CommandText.test.tsx | 44 ++++++++++++++++--- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/app/src/organisms/CommandText/MoveLabwareCommandText.tsx b/app/src/organisms/CommandText/MoveLabwareCommandText.tsx index 7d33a0b3f7f..f2a68a76fd2 100644 --- a/app/src/organisms/CommandText/MoveLabwareCommandText.tsx +++ b/app/src/organisms/CommandText/MoveLabwareCommandText.tsx @@ -1,13 +1,13 @@ import { useTranslation } from 'react-i18next' +import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' +import { getLabwareName } from './utils' +import { getLabwareDisplayLocation } from './utils/getLabwareDisplayLocation' +import { getFinalLabwareLocation } from './utils/getFinalLabwareLocation' import type { CompletedProtocolAnalysis, MoveLabwareRunTimeCommand, RobotType, -} from '@opentrons/shared-data/' -import { getLabwareName } from './utils' -import { getLabwareDisplayLocation } from './utils/getLabwareDisplayLocation' -import { getFinalLabwareLocation } from './utils/getFinalLabwareLocation' - +} from '@opentrons/shared-data' interface MoveLabwareCommandTextProps { command: MoveLabwareRunTimeCommand robotSideAnalysis: CompletedProtocolAnalysis @@ -32,6 +32,12 @@ export function MoveLabwareCommandText( robotType ) + const location = newDisplayLocation.includes( + GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA + ) + ? 'Waste Chute' + : newDisplayLocation + return strategy === 'usingGripper' ? t('move_labware_using_gripper', { labware: getLabwareName(robotSideAnalysis, labwareId), @@ -44,7 +50,7 @@ export function MoveLabwareCommandText( robotType ) : '', - new_location: newDisplayLocation, + new_location: location, }) : t('move_labware_manually', { labware: getLabwareName(robotSideAnalysis, labwareId), @@ -57,6 +63,6 @@ export function MoveLabwareCommandText( robotType ) : '', - new_location: newDisplayLocation, + new_location: location, }) } diff --git a/app/src/organisms/CommandText/__tests__/CommandText.test.tsx b/app/src/organisms/CommandText/__tests__/CommandText.test.tsx index e3e99f2aede..7e739174ce7 100644 --- a/app/src/organisms/CommandText/__tests__/CommandText.test.tsx +++ b/app/src/organisms/CommandText/__tests__/CommandText.test.tsx @@ -1,28 +1,29 @@ import * as React from 'react' import { renderWithProviders } from '@opentrons/components' import { - AspirateInPlaceRunTimeCommand, - BlowoutInPlaceRunTimeCommand, - DispenseInPlaceRunTimeCommand, - DropTipInPlaceRunTimeCommand, FLEX_ROBOT_TYPE, - MoveToAddressableAreaRunTimeCommand, OT2_ROBOT_TYPE, - PrepareToAspirateRunTimeCommand, + GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA, } from '@opentrons/shared-data' import { i18n } from '../../../i18n' import { CommandText } from '../' import { mockRobotSideAnalysis } from '../__fixtures__' import type { + AspirateInPlaceRunTimeCommand, + BlowoutInPlaceRunTimeCommand, BlowoutRunTimeCommand, ConfigureForVolumeRunTimeCommand, + DispenseInPlaceRunTimeCommand, DispenseRunTimeCommand, + DropTipInPlaceRunTimeCommand, DropTipRunTimeCommand, LabwareDefinition2, LoadLabwareRunTimeCommand, LoadLiquidRunTimeCommand, + MoveToAddressableAreaRunTimeCommand, MoveToWellRunTimeCommand, + PrepareToAspirateRunTimeCommand, RunTimeCommand, } from '@opentrons/shared-data' @@ -1280,6 +1281,37 @@ describe('CommandText', () => { 'Moving Opentrons 96 Tip Rack 300 µL using gripper from Slot 9 to off deck' ) }) + it('renders correct text for move labware with gripper to waste chute', () => { + const { getByText } = renderWithProviders( + , + { + i18nInstance: i18n, + } + )[0] + getByText( + 'Moving Opentrons 96 Tip Rack 300 µL using gripper from Slot 9 to Waste Chute' + ) + }) it('renders correct text for move labware with gripper to module', () => { const { getByText } = renderWithProviders( Date: Fri, 8 Dec 2023 16:26:15 -0500 Subject: [PATCH 04/11] fix(app): Remove probe check from module calibration (#14154) * fix(app): remove probe check from module cal Module calibration doesn't require a pipette id and thus none is available here (and in general I believe you can do it with a gripper) so this isn't really an appropriate check. * this was new too whoops probably should have been a sign --- .../ModuleWizardFlows/AttachProbe.tsx | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx index 2e05dedd3ec..ee9d0be66ba 100644 --- a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx @@ -22,7 +22,6 @@ import { import { Banner } from '../../atoms/Banner' import { StyledText } from '../../atoms/text' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { ProbeNotAttached } from '../PipetteWizardFlows/ProbeNotAttached' import type { ModuleCalibrationWizardStepProps } from './types' interface AttachProbeProps extends ModuleCalibrationWizardStepProps { @@ -67,12 +66,8 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { 'module_wizard_flows', 'pipette_wizard_flows', ]) - const [showUnableToDetect, setShowUnableToDetect] = React.useState( - false - ) const moduleDisplayName = getModuleDisplayName(attachedModule.moduleModel) - const pipetteId = attachedPipette.serialNumber const attachedPipetteChannels = attachedPipette.data.channels let pipetteAttachProbeVideoSource, probeLocation @@ -159,12 +154,6 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { setErrorMessage('calibration adapter has not been loaded yet') return } - const verifyCommands: CreateCommand[] = [ - { - commandType: 'verifyTipPresence', - params: { pipetteId: pipetteId, expectedState: 'present' }, - }, - ] const homeCommands: CreateCommand[] = [ { commandType: 'home' as const, @@ -188,18 +177,12 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { }, ] - chainRunCommands?.(verifyCommands, false) + chainRunCommands?.(homeCommands, false) .then(() => { - chainRunCommands?.(homeCommands, false) - .then(() => { - proceed() - }) - .catch((e: Error) => { - setErrorMessage(`error starting module calibration: ${e.message}`) - }) + proceed() }) .catch((e: Error) => { - setShowUnableToDetect(true) + setErrorMessage(`error starting module calibration: ${e.message}`) }) } @@ -225,14 +208,6 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { )} ) - else if (showUnableToDetect) - return ( - - ) // TODO: add calibration loading screen and error screen else return ( From d26e72b853598a84fabdb04d709a6a521a47c01e Mon Sep 17 00:00:00 2001 From: Frank Sinapi Date: Fri, 8 Dec 2023 17:02:08 -0500 Subject: [PATCH 05/11] fix(api): clean up tip motor distance caching/usage (#14156) --- .../backends/ot3controller.py | 40 ++++++++++++------- api/src/opentrons/hardware_control/ot3api.py | 36 +++++++++++------ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 24e6e5cce6d..c212b81b6b4 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -735,13 +735,19 @@ async def home_tip_motors( if not self._feature_flags.stall_detection_enabled else False, ) - positions = await runner.run(can_messenger=self._messenger) - if NodeId.pipette_left in positions: - self._gear_motor_position = { - NodeId.pipette_left: positions[NodeId.pipette_left].motor_position - } - else: - log.debug("no position returned from NodeId.pipette_left") + try: + positions = await runner.run(can_messenger=self._messenger) + if NodeId.pipette_left in positions: + self._gear_motor_position = { + NodeId.pipette_left: positions[NodeId.pipette_left].motor_position + } + else: + log.debug("no position returned from NodeId.pipette_left") + self._gear_motor_position = {} + except Exception as e: + log.error("Clearing tip motor position due to failed movement") + self._gear_motor_position = {} + raise e async def tip_action( self, @@ -755,13 +761,19 @@ async def tip_action( if not self._feature_flags.stall_detection_enabled else False, ) - positions = await runner.run(can_messenger=self._messenger) - if NodeId.pipette_left in positions: - self._gear_motor_position = { - NodeId.pipette_left: positions[NodeId.pipette_left].motor_position - } - else: - log.debug("no position returned from NodeId.pipette_left") + try: + positions = await runner.run(can_messenger=self._messenger) + if NodeId.pipette_left in positions: + self._gear_motor_position = { + NodeId.pipette_left: positions[NodeId.pipette_left].motor_position + } + else: + log.debug("no position returned from NodeId.pipette_left") + self._gear_motor_position = {} + except Exception as e: + log.error("Clearing tip motor position due to failed movement") + self._gear_motor_position = {} + raise e @requires_update @requires_estop diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index cc561def5f5..5d429d7e11f 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -905,10 +905,11 @@ async def home_gear_motors(self) -> None: GantryLoad.HIGH_THROUGHPUT ][OT3AxisKind.Q] + max_distance = self._backend.axis_bounds[Axis.Q][1] # if position is not known, move toward limit switch at a constant velocity - if not any(self._backend.gear_motor_position): + if len(self._backend.gear_motor_position.keys()) == 0: await self._backend.home_tip_motors( - distance=self._backend.axis_bounds[Axis.Q][1], + distance=max_distance, velocity=homing_velocity, ) return @@ -917,7 +918,13 @@ async def home_gear_motors(self) -> None: Axis.P_L ] - if current_pos_float > self._config.safe_home_distance: + # We filter out a distance more than `max_distance` because, if the tip motor was stopped during + # a slow-home motion, the position may be stuck at an enormous large value. + if ( + current_pos_float > self._config.safe_home_distance + and current_pos_float < max_distance + ): + fast_home_moves = self._build_moves( {Axis.Q: current_pos_float}, {Axis.Q: self._config.safe_home_distance} ) @@ -931,7 +938,9 @@ async def home_gear_motors(self) -> None: # move until the limit switch is triggered, with no acceleration await self._backend.home_tip_motors( - distance=(current_pos_float + self._config.safe_home_distance), + distance=min( + current_pos_float + self._config.safe_home_distance, max_distance + ), velocity=homing_velocity, ) @@ -2001,15 +2010,16 @@ async def get_tip_presence_status( Check tip presence status. If a high throughput pipette is present, move the tip motors down before checking the sensor status. """ - real_mount = OT3Mount.from_mount(mount) - async with contextlib.AsyncExitStack() as stack: - if ( - real_mount == OT3Mount.LEFT - and self._gantry_load == GantryLoad.HIGH_THROUGHPUT - ): - await stack.enter_async_context(self._high_throughput_check_tip()) - result = await self._backend.get_tip_status(real_mount) - return result + async with self._motion_lock: + real_mount = OT3Mount.from_mount(mount) + async with contextlib.AsyncExitStack() as stack: + if ( + real_mount == OT3Mount.LEFT + and self._gantry_load == GantryLoad.HIGH_THROUGHPUT + ): + await stack.enter_async_context(self._high_throughput_check_tip()) + result = await self._backend.get_tip_status(real_mount) + return result async def verify_tip_presence( self, mount: Union[top_types.Mount, OT3Mount], expected: TipStateType From d5d396835f8b025cbb3a1c1b511028833642db1b Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Fri, 8 Dec 2023 18:34:43 -0500 Subject: [PATCH 06/11] fix(api): OT2 fixed trash load fix and API 2,15 support for new trash container structure (#14145) Fixes issues introduced to 2,16 protocols for OT2 and 2,15 and prior protocols for OT2 and Flex --- .../protocol_api/core/engine/protocol.py | 3 +- .../core/legacy/legacy_protocol_core.py | 16 +++---- .../opentrons/protocol_api/core/protocol.py | 2 +- .../protocol_api/protocol_context.py | 48 +++++++++++++++++-- .../protocol_engine/state/labware.py | 1 - .../protocol_api/test_protocol_context.py | 27 ++++++++++- .../protocol_api_integration/test_trashes.py | 21 ++++---- 7 files changed, 92 insertions(+), 26 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 54b6e9aab2d..024cd6f17bc 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -132,13 +132,14 @@ def _load_fixed_trash(self) -> None: ) def append_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute] + self, disposal_location: Union[Labware, TrashBin, WasteChute] ) -> None: """Append a disposal location object to the core""" self._disposal_locations.append(disposal_location) def get_disposal_locations(self) -> List[Union[Labware, TrashBin, WasteChute]]: """Get disposal locations.""" + return self._disposal_locations def get_max_speeds(self) -> AxisMaxSpeeds: diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index 635a802864d..dd39504870b 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -88,6 +88,7 @@ def __init__( self._loaded_modules: Set["AbstractModule"] = set() self._module_cores: List[legacy_module_core.LegacyModuleCore] = [] self._labware_cores: List[LegacyLabwareCore] = [self.fixed_trash] + self._disposal_locations: List[Union[Labware, TrashBin, WasteChute]] = [] @property def api_version(self) -> APIVersion: @@ -133,11 +134,13 @@ def is_simulating(self) -> bool: return self._sync_hardware.is_simulator # type: ignore[no-any-return] def append_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute] + self, disposal_location: Union[Labware, TrashBin, WasteChute] ) -> None: - raise APIVersionError( - "Disposal locations are not supported in this API Version." - ) + if isinstance(disposal_location, (TrashBin, WasteChute)): + raise APIVersionError( + "Trash Bin and Waste Chute Disposal locations are not supported in this API Version." + ) + self._disposal_locations.append(disposal_location) def add_labware_definition( self, @@ -383,10 +386,7 @@ def get_loaded_instruments( def get_disposal_locations(self) -> List[Union[Labware, TrashBin, WasteChute]]: """Get valid disposal locations.""" - trash = self._deck_layout["12"] - if isinstance(trash, Labware): - return [trash] - raise APIVersionError("No dynamically loadable disposal locations.") + return self._disposal_locations def pause(self, msg: Optional[str]) -> None: """Pause the protocol.""" diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 056fc532039..6653d6a4bac 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -63,7 +63,7 @@ def add_labware_definition( @abstractmethod def append_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute] + self, disposal_location: Union[Labware, TrashBin, WasteChute] ) -> None: """Append a disposal location object to the core""" ... diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 36d87c5e2f3..7d62b6e84f1 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -23,7 +23,10 @@ from opentrons.commands import protocol_commands as cmds, types as cmd_types from opentrons.commands.publisher import CommandPublisher, publish from opentrons.protocols.api_support import instrument as instrument_support -from opentrons.protocols.api_support.deck_type import NoTrashDefinedError +from opentrons.protocols.api_support.deck_type import ( + NoTrashDefinedError, + should_load_fixed_trash_for_python_protocol, +) from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import ( AxisMaxSpeeds, @@ -138,7 +141,26 @@ def __init__( mount: None for mount in Mount } self._bundled_data: Dict[str, bytes] = bundled_data or {} + + # With the addition of Moveable Trashes and Waste Chute support, it is not necessary + # to ensure that the list of "disposal locations", essentially the list of trashes, + # is initialized correctly on protocols utilizing former API versions prior to 2.16 + # and also to ensure that any protocols after 2.16 intialize a Fixed Trash for OT-2 + # protocols so that no load trash bin behavior is required within the protocol itself. + # Protocols prior to 2.16 expect the Fixed Trash to exist as a Labware object, while + # protocols after 2.16 expect trash to exist as either a TrashBin or WasteChute object. + self._load_fixed_trash() + if should_load_fixed_trash_for_python_protocol(self._api_version): + self._core.append_disposal_location(self.fixed_trash) + elif ( + self._api_version >= APIVersion(2, 16) + and self._core.robot_type == "OT-2 Standard" + ): + _fixed_trash_trashbin = TrashBin( + location=DeckSlotName.FIXED_TRASH, addressable_area_name="fixedTrash" + ) + self._core.append_disposal_location(_fixed_trash_trashbin) self._commands: List[str] = [] self._unsubscribe_commands: Optional[Callable[[], None]] = None @@ -861,10 +883,10 @@ def load_instrument( log=logger, ) - trash: Optional[Labware] + trash: Optional[Union[Labware, TrashBin]] try: trash = self.fixed_trash - except NoTrashDefinedError: + except (NoTrashDefinedError, APIVersionError): trash = None instrument = InstrumentContext( @@ -1024,17 +1046,33 @@ def deck(self) -> Deck: @property # type: ignore @requires_version(2, 0) - def fixed_trash(self) -> Labware: + def fixed_trash(self) -> Union[Labware, TrashBin]: """The trash fixed to slot 12 of the robot deck. - It has one well and should be accessed like labware in your protocol. + In API Versions prior to 2.16 it has one well and should be accessed like labware in your protocol. e.g. ``protocol.fixed_trash['A1']`` + + In API Version 2.16 and above it returns a Trash fixture for OT-2 Protocols. """ + if self._api_version >= APIVersion(2, 16): + if self._core.robot_type == "OT-3 Standard": + raise APIVersionError( + "Fixed Trash is not supported on Flex protocols in API Version 2.16 and above." + ) + disposal_locations = self._core.get_disposal_locations() + if len(disposal_locations) == 0: + raise NoTrashDefinedError( + "No trash container has been defined in this protocol." + ) + if isinstance(disposal_locations[0], TrashBin): + return disposal_locations[0] + fixed_trash = self._core_map.get(self._core.fixed_trash) if fixed_trash is None: raise NoTrashDefinedError( "No trash container has been defined in this protocol." ) + return fixed_trash def _load_fixed_trash(self) -> None: diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index ec43104c832..ec31cd426bc 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -655,7 +655,6 @@ def get_fixed_trash_id(self) -> Optional[str]: DeckSlotName.SLOT_A3, }: return labware.id - return None def is_fixed_trash(self, labware_id: str) -> bool: diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index a129b1657e9..19a1abd202a 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -38,6 +38,9 @@ MagneticModuleCore, MagneticBlockCore, ) +from opentrons.protocols.api_support.deck_type import ( + NoTrashDefinedError, +) @pytest.fixture(autouse=True) @@ -78,6 +81,12 @@ def mock_deck(decoy: Decoy) -> Deck: return decoy.mock(cls=Deck) +@pytest.fixture +def mock_fixed_trash(decoy: Decoy) -> Labware: + """Get a mock Fixed Trash.""" + return decoy.mock(cls=Labware) + + @pytest.fixture def api_version() -> APIVersion: """The API version under test.""" @@ -90,8 +99,11 @@ def subject( mock_core_map: LoadedCoreMap, mock_deck: Deck, api_version: APIVersion, + mock_fixed_trash: Labware, + decoy: Decoy, ) -> ProtocolContext: """Get a ProtocolContext test subject with its dependencies mocked out.""" + decoy.when(mock_core_map.get(mock_core.fixed_trash)).then_return(mock_fixed_trash) return ProtocolContext( api_version=api_version, core=mock_core, @@ -115,7 +127,7 @@ def test_fixed_trash( trash = trash_captor.value decoy.when(mock_core_map.get(mock_core.fixed_trash)).then_return(trash) - + decoy.when(mock_core.get_disposal_locations()).then_return([trash]) result = subject.fixed_trash assert result is trash @@ -152,6 +164,9 @@ def test_load_instrument( ).then_return(mock_instrument_core) decoy.when(mock_instrument_core.get_pipette_name()).then_return("Gandalf the Grey") + decoy.when(mock_core.get_disposal_locations()).then_raise( + NoTrashDefinedError("No trash!") + ) result = subject.load_instrument( instrument_name="Gandalf", mount="shadowfax", tip_racks=mock_tip_racks @@ -196,6 +211,9 @@ def test_load_instrument_replace( ) ).then_return(mock_instrument_core) decoy.when(mock_instrument_core.get_pipette_name()).then_return("Ada Lovelace") + decoy.when(mock_core.get_disposal_locations()).then_raise( + NoTrashDefinedError("No trash!") + ) pipette_1 = subject.load_instrument(instrument_name="ada", mount=Mount.RIGHT) assert subject.loaded_instruments["right"] is pipette_1 @@ -229,6 +247,9 @@ def test_96_channel_pipette_always_loads_on_the_left_mount( mount=Mount.LEFT, ) ).then_return(mock_instrument_core) + decoy.when(mock_core.get_disposal_locations()).then_raise( + NoTrashDefinedError("No trash!") + ) result = subject.load_instrument( instrument_name="A 96 Channel Name", mount="shadowfax" @@ -261,6 +282,10 @@ def test_96_channel_pipette_raises_if_another_pipette_attached( decoy.when(mock_instrument_core.get_pipette_name()).then_return("ada") + decoy.when(mock_core.get_disposal_locations()).then_raise( + NoTrashDefinedError("No trash!") + ) + pipette_1 = subject.load_instrument(instrument_name="ada", mount=Mount.RIGHT) assert subject.loaded_instruments["right"] is pipette_1 diff --git a/api/tests/opentrons/protocol_api_integration/test_trashes.py b/api/tests/opentrons/protocol_api_integration/test_trashes.py index b8436f6ce74..1ad9a945c9a 100644 --- a/api/tests/opentrons/protocol_api_integration/test_trashes.py +++ b/api/tests/opentrons/protocol_api_integration/test_trashes.py @@ -25,12 +25,6 @@ "2.16", "OT-2", protocol_api.TrashBin, - marks=[ - pytest.mark.ot3_only, # Simulating a Flex protocol requires a Flex hardware API. - pytest.mark.xfail( - strict=True, reason="https://opentrons.atlassian.net/browse/RSS-417" - ), - ], ), pytest.param( "2.16", @@ -58,7 +52,10 @@ def test_fixed_trash_presence( ) if expected_trash_class is None: - with pytest.raises(Exception, match="No trash container has been defined"): + with pytest.raises( + Exception, + match="Fixed Trash is not supported on Flex protocols in API Version 2.16 and above.", + ): protocol.fixed_trash with pytest.raises(Exception, match="No trash container has been defined"): instrument.trash_container @@ -75,7 +72,10 @@ def test_trash_search() -> None: instrument = protocol.load_instrument("flex_1channel_50", mount="left") # By default, there should be no trash. - with pytest.raises(Exception, match="No trash container has been defined"): + with pytest.raises( + Exception, + match="Fixed Trash is not supported on Flex protocols in API Version 2.16 and above.", + ): protocol.fixed_trash with pytest.raises(Exception, match="No trash container has been defined"): instrument.trash_container @@ -84,7 +84,10 @@ def test_trash_search() -> None: loaded_second = protocol.load_trash_bin("B1") # After loading some trashes, there should still be no protocol.fixed_trash... - with pytest.raises(Exception, match="No trash container has been defined"): + with pytest.raises( + Exception, + match="Fixed Trash is not supported on Flex protocols in API Version 2.16 and above.", + ): protocol.fixed_trash # ...but instrument.trash_container should automatically update to point to # the first trash that we loaded. From f7caea6c73bb6b22dd659550d79c3a5db9f9c197 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Mon, 11 Dec 2023 10:10:05 -0500 Subject: [PATCH 07/11] fix(app): properly display no liquids used text in protocol details/setup (#14144) * fix(app): properly display no liquids used text in protocol details/setup closes RQA-2006 --- .../Devices/ProtocolRun/ProtocolRunSetup.tsx | 17 ++++++++++++++--- .../__tests__/ProtocolRunSetup.test.tsx | 9 ++++++++- .../ProtocolDetails/ProtocolLiquidsDetails.tsx | 2 +- .../__tests__/ProtocolLiquidsDetails.test.tsx | 1 + 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index e6e07c9f3f8..e1d3180ee2b 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -1,7 +1,10 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { parseAllRequiredModuleModels } from '@opentrons/api-client' +import { + parseAllRequiredModuleModels, + parseLiquidsInLoadOrder, +} from '@opentrons/api-client' import { ALIGN_CENTER, COLORS, @@ -133,8 +136,16 @@ export function ProtocolRunSetup({ }) if (robot == null) return null - const hasLiquids = - protocolAnalysis != null && protocolAnalysis.liquids?.length > 0 + + const liquids = protocolAnalysis?.liquids ?? [] + + const liquidsInLoadOrder = + protocolAnalysis != null + ? parseLiquidsInLoadOrder(liquids, protocolAnalysis.commands) + : [] + + const hasLiquids = liquidsInLoadOrder.length > 0 + const hasModules = protocolAnalysis != null && modules.length > 0 const protocolDeckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index bd50b4e78a1..01b1c28c40c 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -1,7 +1,10 @@ import * as React from 'react' import { when, resetAllWhenMocks } from 'jest-when' -import { parseAllRequiredModuleModels } from '@opentrons/api-client' +import { + parseAllRequiredModuleModels, + parseLiquidsInLoadOrder, +} from '@opentrons/api-client' import { partialComponentPropsMatcher, renderWithProviders, @@ -76,6 +79,9 @@ const mockUseStoredProtocolAnalysis = useStoredProtocolAnalysis as jest.MockedFu const mockParseAllRequiredModuleModels = parseAllRequiredModuleModels as jest.MockedFunction< typeof parseAllRequiredModuleModels > +const mockParseLiquidsInLoadOrder = parseLiquidsInLoadOrder as jest.MockedFunction< + typeof parseLiquidsInLoadOrder +> const mockSetupLabware = SetupLabware as jest.MockedFunction< typeof SetupLabware > @@ -142,6 +148,7 @@ describe('ProtocolRunSetup', () => { ...MOCK_ROTOCOL_LIQUID_KEY, } as unknown) as ProtocolAnalysisOutput) when(mockParseAllRequiredModuleModels).mockReturnValue([]) + when(mockParseLiquidsInLoadOrder).mockReturnValue([]) when(mockUseRobot) .calledWith(ROBOT_NAME) .mockReturnValue(mockConnectedRobot) diff --git a/app/src/organisms/ProtocolDetails/ProtocolLiquidsDetails.tsx b/app/src/organisms/ProtocolDetails/ProtocolLiquidsDetails.tsx index acc3d6bdc27..a621f0a7b14 100644 --- a/app/src/organisms/ProtocolDetails/ProtocolLiquidsDetails.tsx +++ b/app/src/organisms/ProtocolDetails/ProtocolLiquidsDetails.tsx @@ -45,7 +45,7 @@ export const ProtocolLiquidsDetails = ( overflowY="auto" data-testid="LiquidsDetailsTab" > - {liquids.length > 0 ? ( + {liquidsInLoadOrder.length > 0 ? ( liquidsInLoadOrder?.map((liquid, index) => { return ( diff --git a/app/src/organisms/ProtocolDetails/__tests__/ProtocolLiquidsDetails.test.tsx b/app/src/organisms/ProtocolDetails/__tests__/ProtocolLiquidsDetails.test.tsx index e657317e2e7..48a227b8367 100644 --- a/app/src/organisms/ProtocolDetails/__tests__/ProtocolLiquidsDetails.test.tsx +++ b/app/src/organisms/ProtocolDetails/__tests__/ProtocolLiquidsDetails.test.tsx @@ -63,6 +63,7 @@ describe('ProtocolLiquidsDetails', () => { }) it('renders the correct info for no liquids in the protocol', () => { props.liquids = [] + mockParseLiquidsInLoadOrder.mockReturnValue([]) const [{ getByText, getByLabelText }] = render(props) getByText('No liquids are specified for this protocol') getByLabelText('ProtocolLIquidsDetails_noLiquidsIcon') From 6f14276e60e7f1fde1858c0dd250a0af12b4f4b2 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Mon, 11 Dec 2023 16:09:49 -0500 Subject: [PATCH 08/11] feat(app): disable robot overflow menu when robot has an existing maintenance run (#14162) closes RQA-1170 --- .../hooks/__tests__/useIsRobotBusy.test.ts | 29 ++++++++++++------- .../organisms/Devices/hooks/useIsRobotBusy.ts | 4 +++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts index fd6a466c5e8..e765638ebfd 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts +++ b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts @@ -3,6 +3,7 @@ import { useAllSessionsQuery, useAllRunsQuery, useEstopQuery, + useCurrentMaintenanceRun, } from '@opentrons/react-api-client' import { DISENGAGED, @@ -34,6 +35,9 @@ const mockUseAllSessionsQuery = useAllSessionsQuery as jest.MockedFunction< const mockUseAllRunsQuery = useAllRunsQuery as jest.MockedFunction< typeof useAllRunsQuery > +const mockUseCurrentMaintenanceRun = useCurrentMaintenanceRun as jest.MockedFunction< + typeof useCurrentMaintenanceRun +> const mockUseEstopQuery = useEstopQuery as jest.MockedFunction< typeof useEstopQuery > @@ -51,6 +55,9 @@ describe('useIsRobotBusy', () => { }, }, } as UseQueryResult) + mockUseCurrentMaintenanceRun.mockReturnValue({ + data: {}, + } as any) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) mockUseIsFlex.mockReturnValue(false) }) @@ -180,15 +187,15 @@ describe('useIsRobotBusy', () => { expect(result).toBe(false) }) - // TODO: kj 07/13/2022 This test is temporary pending but should be solved by another PR. - // it('should poll the run and sessions if poll option is true', async () => { - // const result = useIsRobotBusy({ poll: true }) - // expect(result).toBe(true) - - // act(() => { - // jest.advanceTimersByTime(30000) - // }) - // expect(mockUseAllRunsQuery).toHaveBeenCalled() - // expect(mockUseAllSessionsQuery).toHaveBeenCalled() - // }) + it('returns true when a maintenance run exists', () => { + mockUseCurrentMaintenanceRun.mockReturnValue({ + data: { + data: { + id: '123', + }, + }, + } as any) + const result = useIsRobotBusy() + expect(result).toBe(true) + }) }) diff --git a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts index ffc09204d45..5050c3d5dfa 100644 --- a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts +++ b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts @@ -3,6 +3,7 @@ import { useAllRunsQuery, useEstopQuery, useHost, + useCurrentMaintenanceRun, } from '@opentrons/react-api-client' import { DISENGAGED } from '../../EmergencyStop' import { useIsFlex } from './useIsFlex' @@ -19,6 +20,8 @@ export function useIsRobotBusy( const queryOptions = poll ? { refetchInterval: ROBOT_STATUS_POLL_MS } : {} const robotHasCurrentRun = useAllRunsQuery({}, queryOptions)?.data?.links?.current != null + const { data: maintenanceRunData } = useCurrentMaintenanceRun(queryOptions) + const isMaintenanceRunExisting = maintenanceRunData?.data?.id != null const allSessionsQueryResponse = useAllSessionsQuery(queryOptions) const host = useHost() const robotName = host?.robotName @@ -30,6 +33,7 @@ export function useIsRobotBusy( return ( robotHasCurrentRun || + isMaintenanceRunExisting || (allSessionsQueryResponse?.data?.data != null && allSessionsQueryResponse?.data?.data?.length !== 0) || (isFlex && estopStatus?.data.status !== DISENGAGED && estopError == null) From 11d65b3401ccd3402466891ec256fe84a1d83372 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Mon, 11 Dec 2023 17:00:34 -0500 Subject: [PATCH 09/11] fix(app): case insensitively sort labware list (#14165) closes RQA-1955 --- app/src/pages/Labware/hooks.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/pages/Labware/hooks.tsx b/app/src/pages/Labware/hooks.tsx index f57bafbcef9..caf37544be5 100644 --- a/app/src/pages/Labware/hooks.tsx +++ b/app/src/pages/Labware/hooks.tsx @@ -37,10 +37,16 @@ export function useAllLabware( : null ) const sortLabware = (a: LabwareDefAndDate, b: LabwareDefAndDate): number => { - if (a.definition.metadata.displayName < b.definition.metadata.displayName) { + if ( + a.definition.metadata.displayName.toUpperCase() < + b.definition.metadata.displayName.toUpperCase() + ) { return sortBy === 'alphabetical' ? -1 : 1 } - if (a.definition.metadata.displayName > b.definition.metadata.displayName) { + if ( + a.definition.metadata.displayName.toUpperCase() > + b.definition.metadata.displayName.toUpperCase() + ) { return sortBy === 'alphabetical' ? 1 : -1 } return 0 From 018d7bcb29511b5925b5c186d9cdabf5bc7aeb7f Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Mon, 11 Dec 2023 17:16:00 -0500 Subject: [PATCH 10/11] feat(app): resolve single slot location conflict (#14158) adds the necessary processing to useDeckConfigurationCompatibility to surface a required protocol single slot fixture conflict and provide the missing on-deck labware display name for that single slot. updates ODD FixtureTable to fix a location conflict modal bug where incorrect data was shown in the modal when multiple location conflicts exist closes RAUT-885, RQA-1985 --- .../Devices/ProtocolRun/ProtocolRunSetup.tsx | 15 +- .../LocationConflictModal.tsx | 23 +- .../SetupModuleAndDeck/SetupFixtureList.tsx | 10 +- .../__tests__/LocationConflictModal.test.tsx | 31 +++ .../__tests__/SetupFixtureList.test.tsx | 1 + .../__tests__/SetupModulesAndDeck.test.tsx | 2 + .../ProtocolRun/SetupModuleAndDeck/index.tsx | 7 - .../__tests__/ProtocolRunHeader.test.tsx | 1 + .../__tests__/ProtocolRunSetup.test.tsx | 1 + .../FixtureTable.tsx | 235 ++++++++++-------- .../__tests__/FixtureTable.test.tsx | 1 + app/src/resources/deck_configuration/hooks.ts | 48 +++- app/src/resources/deck_configuration/utils.ts | 36 +-- .../hardware-sim/ProtocolDeck/utils/index.ts | 1 + components/src/hardware-sim/index.ts | 1 + 15 files changed, 240 insertions(+), 173 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index e1d3180ee2b..0226ca73752 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -16,11 +16,7 @@ import { SPACING, TYPOGRAPHY, } from '@opentrons/components' -import { - FLEX_ROBOT_TYPE, - getSimplestDeckConfigForProtocol, - OT2_ROBOT_TYPE, -} from '@opentrons/shared-data' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { Line } from '../../../atoms/structure' import { StyledText } from '../../../atoms/text' @@ -148,11 +144,12 @@ export function ProtocolRunSetup({ const hasModules = protocolAnalysis != null && modules.length > 0 - const protocolDeckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) - - const requiredDeckConfig = getRequiredDeckConfig(protocolDeckConfig) + // need config compatibility (including check for single slot conflicts) + const requiredDeckConfigCompatibility = getRequiredDeckConfig( + deckConfigCompatibility + ) - const hasFixtures = requiredDeckConfig.length > 0 + const hasFixtures = requiredDeckConfigCompatibility.length > 0 let moduleDescription: string = t(`${MODULE_SETUP_KEY}_description`, { count: modules.length, diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx index 9f85cd93393..be6b12b1d90 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx @@ -44,6 +44,7 @@ import type { interface LocationConflictModalProps { onCloseClick: () => void cutoutId: CutoutId + missingLabwareDisplayName?: string | null requiredFixtureId?: CutoutFixtureId requiredModule?: ModuleModel isOnDevice?: boolean @@ -55,6 +56,7 @@ export const LocationConflictModal = ( const { onCloseClick, cutoutId, + missingLabwareDisplayName, requiredFixtureId, requiredModule, isOnDevice = false, @@ -96,6 +98,15 @@ export const LocationConflictModal = ( onCloseClick() } + let protocolSpecifiesDisplayName = '' + if (missingLabwareDisplayName != null) { + protocolSpecifiesDisplayName = missingLabwareDisplayName + } else if (requiredFixtureId != null) { + protocolSpecifiesDisplayName = getFixtureDisplayName(requiredFixtureId) + } else if (requiredModule != null) { + protocolSpecifiesDisplayName = getModuleDisplayName(requiredModule) + } + return ( {isOnDevice ? ( @@ -149,12 +160,7 @@ export const LocationConflictModal = ( {t('protocol_specifies')} - - {requiredFixtureId != null && - getFixtureDisplayName(requiredFixtureId)} - {requiredModule != null && - getModuleDisplayName(requiredModule)} - + {protocolSpecifiesDisplayName} - {requiredFixtureId != null && - getFixtureDisplayName(requiredFixtureId)} - {requiredModule != null && - getModuleDisplayName(requiredModule)} + {protocolSpecifiesDisplayName} setShowLocationConflictModal(false)} cutoutId={cutoutId} + missingLabwareDisplayName={missingLabwareDisplayName} requiredFixtureId={compatibleCutoutFixtureIds[0]} /> ) : null} @@ -180,7 +183,8 @@ export function FixtureListItem({ width="60px" height="54px" src={ - isCurrentFixtureCompatible + // show the current fixture for a missing single slot + isCurrentFixtureCompatible || isRequiredSingleSlotMissing ? getFixtureImage(cutoutFixtureId) : getFixtureImage(compatibleCutoutFixtureIds?.[0]) } @@ -191,7 +195,7 @@ export function FixtureListItem({ css={TYPOGRAPHY.pSemiBold} marginLeft={SPACING.spacing20} > - {isCurrentFixtureCompatible + {isCurrentFixtureCompatible || isRequiredSingleSlotMissing ? getFixtureDisplayName(cutoutFixtureId) : getFixtureDisplayName(compatibleCutoutFixtureIds?.[0])} @@ -232,7 +236,7 @@ export function FixtureListItem({ } > - {t('update_deck')} + {t('resolve')} ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx index f79544bcddd..8c6377050c7 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { UseQueryResult } from 'react-query' import { renderWithProviders } from '@opentrons/components' import { + SINGLE_RIGHT_SLOT_FIXTURE, STAGING_AREA_RIGHT_SLOT_FIXTURE, TRASH_BIN_ADAPTER_FIXTURE, } from '@opentrons/shared-data' @@ -50,6 +51,9 @@ describe('LocationConflictModal', () => { updateDeckConfiguration: mockUpdate, } as any) }) + afterEach(() => { + jest.resetAllMocks() + }) it('should render the modal information for a fixture conflict', () => { const { getByText, getAllByText, getByRole } = render(props) getByText('Deck location conflict') @@ -78,6 +82,33 @@ describe('LocationConflictModal', () => { getByRole('button', { name: 'Update deck' }).click() expect(mockUpdate).toHaveBeenCalled() }) + it('should render the modal information for a single slot fixture conflict', () => { + mockUseDeckConfigurationQuery.mockReturnValue({ + data: [ + { + cutoutId: 'cutoutB1', + cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE, + }, + ], + } as UseQueryResult) + props = { + onCloseClick: jest.fn(), + cutoutId: 'cutoutB1', + requiredFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, + missingLabwareDisplayName: 'a tiprack', + } + const { getByText, getAllByText, getByRole } = render(props) + getByText('Deck location conflict') + getByText('Slot B1') + getByText('Protocol specifies') + getByText('Currently configured') + getAllByText('Trash bin') + getByText('a tiprack') + getByRole('button', { name: 'Cancel' }).click() + expect(props.onCloseClick).toHaveBeenCalled() + getByRole('button', { name: 'Update deck' }).click() + expect(mockUpdate).toHaveBeenCalled() + }) it('should render correct info for a odd', () => { props = { ...props, diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx index d9245a370e4..b2fba1e541a 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx @@ -34,6 +34,7 @@ const mockDeckConfigCompatibility: CutoutConfigAndCompatibility[] = [ compatibleCutoutFixtureIds: [ STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, ], + missingLabwareDisplayName: null, }, ] diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesAndDeck.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesAndDeck.test.tsx index 3fa3841d696..96b7a907a22 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesAndDeck.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesAndDeck.test.tsx @@ -141,6 +141,8 @@ describe('SetupModuleAndDeck', () => { cutoutId: 'cutoutA1', cutoutFixtureId: 'trashBinAdapter', requiredAddressableAreas: ['movableTrashA1'], + compatibleCutoutFixtureIds: ['trashBinAdapter'], + missingLabwareDisplayName: null, }, ]) const { getByRole, getByText } = render(props) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx index ad5de80fe7a..4e9afd58604 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx @@ -14,7 +14,6 @@ import { useDeckConfigurationCompatibility } from '../../../../resources/deck_co import { getIsFixtureMismatch, getRequiredDeckConfig, - // getUnmatchedSingleSlotFixtures, } from '../../../../resources/deck_configuration/utils' import { Tooltip } from '../../../../atoms/Tooltip' import { @@ -66,12 +65,6 @@ export const SetupModuleAndDeck = ({ const isFixtureMismatch = getIsFixtureMismatch(deckConfigCompatibility) - // TODO(bh, 2023-11-28): there is an unimplemented scenario where unmatched single slot fixtures need to be updated - // will need to additionally filter out module conflict unmatched fixtures, as these are represented in SetupModulesList - // const unmatchedSingleSlotFixtures = getUnmatchedSingleSlotFixtures( - // deckConfigCompatibility - // ) - const requiredDeckConfigCompatibility = getRequiredDeckConfig( deckConfigCompatibility ) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index f0d0534c583..70273153565 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -581,6 +581,7 @@ describe('ProtocolRunHeader', () => { compatibleCutoutFixtureIds: [ STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, ], + missingLabwareDisplayName: null, }, ]) when(mockGetIsFixtureMismatch).mockReturnValue(true) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 01b1c28c40c..89c41109a7f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -353,6 +353,7 @@ describe('ProtocolRunSetup', () => { compatibleCutoutFixtureIds: [ STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, ], + missingLabwareDisplayName: null, }, ]) when(mockGetRequiredDeckConfig).mockReturnValue([ diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx index 85ea3155229..66ea3e9158f 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx @@ -14,13 +14,13 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { - FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS, getCutoutDisplayName, getFixtureDisplayName, getSimplestDeckConfigForProtocol, SINGLE_SLOT_FIXTURES, } from '@opentrons/shared-data' import { useDeckConfigurationCompatibility } from '../../resources/deck_configuration/hooks' +import { getRequiredDeckConfig } from '../../resources/deck_configuration/utils' import { LocationConflictModal } from '../Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal' import { StyledText } from '../../atoms/text' import { Chip } from '../../atoms/Chip' @@ -32,6 +32,7 @@ import type { RobotType, } from '@opentrons/shared-data' import type { SetupScreens } from '../../pages/OnDeviceDisplay/ProtocolSetup' +import type { CutoutConfigAndCompatibility } from '../../resources/deck_configuration/hooks' interface FixtureTableProps { robotType: RobotType @@ -48,12 +49,7 @@ export function FixtureTable({ setCutoutId, setProvidedFixtureOptions, }: FixtureTableProps): JSX.Element | null { - const { t, i18n } = useTranslation('protocol_setup') - - const [ - showLocationConflictModal, - setShowLocationConflictModal, - ] = React.useState(false) + const { t } = useTranslation('protocol_setup') const requiredFixtureDetails = getSimplestDeckConfigForProtocol( mostRecentAnalysis @@ -63,16 +59,8 @@ export function FixtureTable({ mostRecentAnalysis ) - const nonSingleSlotDeckConfigCompatibility = deckConfigCompatibility.filter( - ({ requiredAddressableAreas }) => - // required AA list includes a non-single-slot AA - !requiredAddressableAreas.every(aa => - FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS.includes(aa) - ) - ) - // fixture includes at least 1 required AA - const requiredDeckConfigCompatibility = nonSingleSlotDeckConfigCompatibility.filter( - fixture => fixture.requiredAddressableAreas.length > 0 + const requiredDeckConfigCompatibility = getRequiredDeckConfig( + deckConfigCompatibility ) return requiredDeckConfigCompatibility.length > 0 ? ( @@ -89,98 +77,129 @@ export function FixtureTable({ {t('location')} {t('status')} - {requiredDeckConfigCompatibility.map( - ({ cutoutId, cutoutFixtureId, compatibleCutoutFixtureIds }, index) => { - const isCurrentFixtureCompatible = - cutoutFixtureId != null && - compatibleCutoutFixtureIds.includes(cutoutFixtureId) + {requiredDeckConfigCompatibility.map((fixtureCompatibility, index) => { + return ( + + ) + })} + + ) : null +} + +interface FixtureTableItemProps extends CutoutConfigAndCompatibility { + lastItem: boolean + setSetupScreen: React.Dispatch> + setCutoutId: (cutoutId: CutoutId) => void + setProvidedFixtureOptions: (providedFixtureOptions: CutoutFixtureId[]) => void +} - let chipLabel: JSX.Element - let handleClick - if (!isCurrentFixtureCompatible) { - const isConflictingFixtureConfigured = - cutoutFixtureId != null && - !SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) - chipLabel = ( - <> - - - - ) - handleClick = isConflictingFixtureConfigured - ? () => setShowLocationConflictModal(true) - : () => { - setCutoutId(cutoutId) - setProvidedFixtureOptions(compatibleCutoutFixtureIds) - setSetupScreen('deck configuration') - } - } else { - chipLabel = ( - - ) +function FixtureTableItem({ + cutoutId, + cutoutFixtureId, + compatibleCutoutFixtureIds, + missingLabwareDisplayName, + lastItem, + setSetupScreen, + setCutoutId, + setProvidedFixtureOptions, +}: FixtureTableItemProps): JSX.Element { + const { t, i18n } = useTranslation('protocol_setup') + + const [ + showLocationConflictModal, + setShowLocationConflictModal, + ] = React.useState(false) + + const isCurrentFixtureCompatible = + cutoutFixtureId != null && + compatibleCutoutFixtureIds.includes(cutoutFixtureId) + const isRequiredSingleSlotMissing = missingLabwareDisplayName != null + let chipLabel: JSX.Element + let handleClick + if (!isCurrentFixtureCompatible) { + const isConflictingFixtureConfigured = + cutoutFixtureId != null && !SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) + chipLabel = ( + <> + - {showLocationConflictModal ? ( - setShowLocationConflictModal(false)} - cutoutId={cutoutId} - requiredFixtureId={compatibleCutoutFixtureIds[0]} - isOnDevice={true} - /> - ) : null} - - - - {cutoutFixtureId != null && isCurrentFixtureCompatible - ? getFixtureDisplayName(cutoutFixtureId) - : getFixtureDisplayName(compatibleCutoutFixtureIds?.[0])} - - - - - - - {chipLabel} - - - - ) + type="warning" + background={false} + iconName="connection-status" + /> + + + ) + handleClick = isConflictingFixtureConfigured + ? () => setShowLocationConflictModal(true) + : () => { + setCutoutId(cutoutId) + setProvidedFixtureOptions(compatibleCutoutFixtureIds) + setSetupScreen('deck configuration') } - )} - - ) : null + } else { + chipLabel = ( + + ) + } + return ( + + {showLocationConflictModal ? ( + setShowLocationConflictModal(false)} + cutoutId={cutoutId} + requiredFixtureId={compatibleCutoutFixtureIds[0]} + isOnDevice={true} + missingLabwareDisplayName={missingLabwareDisplayName} + /> + ) : null} + + + + {cutoutFixtureId != null && + (isCurrentFixtureCompatible || isRequiredSingleSlotMissing) + ? getFixtureDisplayName(cutoutFixtureId) + : getFixtureDisplayName(compatibleCutoutFixtureIds?.[0])} + + + + + + + {chipLabel} + + + + ) } diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/FixtureTable.test.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/FixtureTable.test.tsx index 7bd357c8b7f..b29f25b1c6a 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/FixtureTable.test.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/FixtureTable.test.tsx @@ -51,6 +51,7 @@ describe('FixtureTable', () => { compatibleCutoutFixtureIds: [ STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, ], + missingLabwareDisplayName: null, }, ]) }) diff --git a/app/src/resources/deck_configuration/hooks.ts b/app/src/resources/deck_configuration/hooks.ts index b5a5e65eec5..a05c5e68f62 100644 --- a/app/src/resources/deck_configuration/hooks.ts +++ b/app/src/resources/deck_configuration/hooks.ts @@ -1,3 +1,4 @@ +import { getLabwareOnDeck } from '@opentrons/components' import { useDeckConfigurationQuery } from '@opentrons/react-api-client' import { FLEX_ROBOT_TYPE, @@ -5,6 +6,7 @@ import { getCutoutFixturesForCutoutId, getCutoutIdForAddressableArea, getDeckDefFromRobotType, + SINGLE_SLOT_FIXTURES, } from '@opentrons/shared-data' import type { @@ -19,6 +21,8 @@ const DECK_CONFIG_REFETCH_INTERVAL = 5000 export interface CutoutConfigAndCompatibility extends CutoutConfigProtocolSpec { compatibleCutoutFixtureIds: CutoutFixtureId[] + // the missing on-deck labware display name for a single slot cutout + missingLabwareDisplayName: string | null } export function useDeckConfigurationCompatibility( robotType: RobotType, @@ -33,6 +37,9 @@ export function useDeckConfigurationCompatibility( protocolAnalysis != null ? getAddressableAreasInProtocol(protocolAnalysis) : [] + const labwareOnDeck = + protocolAnalysis != null ? getLabwareOnDeck(protocolAnalysis) : [] + return deckConfig.reduce( (acc, { cutoutId, cutoutFixtureId }) => { const fixturesThatMountToCutoutId = getCutoutFixturesForCutoutId( @@ -45,19 +52,46 @@ export function useDeckConfigurationCompatibility( cutoutId ) + const compatibleCutoutFixtureIds = fixturesThatMountToCutoutId + .filter(cf => + requiredAddressableAreasForCutoutId.every(aa => + cf.providesAddressableAreas[cutoutId].includes(aa) + ) + ) + .map(cf => cf.id) + + // get the on-deck labware name for a missing single-slot addressable area + const missingSingleSlotLabware = + cutoutFixtureId != null && + // fixture mismatch + !compatibleCutoutFixtureIds.includes(cutoutFixtureId) && + compatibleCutoutFixtureIds[0] != null && + // compatible fixture is single-slot + SINGLE_SLOT_FIXTURES.includes(compatibleCutoutFixtureIds[0]) + ? labwareOnDeck.find( + ({ labwareLocation }) => + labwareLocation !== 'offDeck' && + // match the addressable area to an on-deck labware + (('slotName' in labwareLocation && + requiredAddressableAreasForCutoutId[0] === + labwareLocation.slotName) || + ('addressableAreaName' in labwareLocation && + requiredAddressableAreasForCutoutId[0] === + labwareLocation.addressableAreaName)) + ) + : null + + const missingLabwareDisplayName = + missingSingleSlotLabware?.displayName ?? null + return [ ...acc, { cutoutId, cutoutFixtureId: cutoutFixtureId, requiredAddressableAreas: requiredAddressableAreasForCutoutId, - compatibleCutoutFixtureIds: fixturesThatMountToCutoutId - .filter(cf => - requiredAddressableAreasForCutoutId.every(aa => - cf.providesAddressableAreas[cutoutId].includes(aa) - ) - ) - .map(cf => cf.id), + compatibleCutoutFixtureIds, + missingLabwareDisplayName, }, ] }, diff --git a/app/src/resources/deck_configuration/utils.ts b/app/src/resources/deck_configuration/utils.ts index a02efa373c5..9efaeea3a57 100644 --- a/app/src/resources/deck_configuration/utils.ts +++ b/app/src/resources/deck_configuration/utils.ts @@ -1,20 +1,17 @@ import { FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS } from '@opentrons/shared-data' -import type { - CutoutConfigProtocolSpec, - CutoutFixtureId, -} from '@opentrons/shared-data' +import type { CutoutFixtureId } from '@opentrons/shared-data' import type { CutoutConfigAndCompatibility } from './hooks' -export function getRequiredDeckConfig( - deckConfigProtocolSpec: T[] -): T[] { +export function getRequiredDeckConfig( + deckConfigProtocolSpec: CutoutConfigAndCompatibility[] +): CutoutConfigAndCompatibility[] { const nonSingleSlotDeckConfigCompatibility = deckConfigProtocolSpec.filter( - ({ requiredAddressableAreas }) => - // required AA list includes a non-single-slot AA + ({ missingLabwareDisplayName, requiredAddressableAreas }) => + // required AA list includes a non-single-slot AA or a missing labware display name !requiredAddressableAreas.every(aa => FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS.includes(aa) - ) + ) || missingLabwareDisplayName != null ) // fixture includes at least 1 required AA const requiredDeckConfigProtocolSpec = nonSingleSlotDeckConfigCompatibility.filter( @@ -24,25 +21,6 @@ export function getRequiredDeckConfig( return requiredDeckConfigProtocolSpec } -export function getUnmatchedSingleSlotFixtures( - deckConfigProtocolSpec: CutoutConfigAndCompatibility[] -): CutoutConfigAndCompatibility[] { - const singleSlotDeckConfigCompatibility = deckConfigProtocolSpec.filter( - ({ requiredAddressableAreas }) => - // required AA list includes only single-slot AA - requiredAddressableAreas.every(aa => - FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS.includes(aa) - ) - ) - // fixture includes at least 1 required AA - const unmatchedSingleSlotDeckConfigCompatibility = singleSlotDeckConfigCompatibility.filter( - ({ cutoutFixtureId, compatibleCutoutFixtureIds }) => - !isMatchedFixture(cutoutFixtureId, compatibleCutoutFixtureIds) - ) - - return unmatchedSingleSlotDeckConfigCompatibility -} - export function getIsFixtureMismatch( deckConfigProtocolSpec: CutoutConfigAndCompatibility[] ): boolean { diff --git a/components/src/hardware-sim/ProtocolDeck/utils/index.ts b/components/src/hardware-sim/ProtocolDeck/utils/index.ts index 9028755b545..9afd80722dd 100644 --- a/components/src/hardware-sim/ProtocolDeck/utils/index.ts +++ b/components/src/hardware-sim/ProtocolDeck/utils/index.ts @@ -1 +1,2 @@ +export * from './getLabwareOnDeck' export * from './getStandardDeckViewLayerBlockList' diff --git a/components/src/hardware-sim/index.ts b/components/src/hardware-sim/index.ts index 72e1117f842..5ff502ef4e2 100644 --- a/components/src/hardware-sim/index.ts +++ b/components/src/hardware-sim/index.ts @@ -1,6 +1,7 @@ export * from './BaseDeck' export * from './BaseDeck/__fixtures__' export * from './ProtocolDeck' +export * from './ProtocolDeck/utils' export * from './Deck' export * from './DeckConfigurator' export * from './DeckSlotLocation' From 3a93fc48815afa4acc20e5ef0e76a89390c6ccc2 Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 11 Dec 2023 18:22:10 -0500 Subject: [PATCH 11/11] fix(app): fix module calibration selection slot issue (#14168) * fix(app): fix module calibration selection slot issue --- app/src/organisms/ModuleWizardFlows/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/ModuleWizardFlows/constants.ts b/app/src/organisms/ModuleWizardFlows/constants.ts index 72cbe4fdfef..2ed222c5dd1 100644 --- a/app/src/organisms/ModuleWizardFlows/constants.ts +++ b/app/src/organisms/ModuleWizardFlows/constants.ts @@ -25,8 +25,8 @@ export const SCREWDRIVER_LOADNAME = 'hex_screwdriver' as const export const FLEX_SLOT_NAMES_BY_MOD_TYPE: { [moduleType in ModuleType]?: string[] } = { - [HEATERSHAKER_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3'], - [TEMPERATURE_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3'], + [HEATERSHAKER_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3', 'A3'], + [TEMPERATURE_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3', 'A3'], [THERMOCYCLER_MODULE_TYPE]: ['B1'], } export const LEFT_SLOTS: string[] = ['A1', 'B1', 'C1', 'D1']