From 0c40f7d891ffd96fd9494ee26174489d33d7790e Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Fri, 10 May 2024 16:01:21 -0400 Subject: [PATCH] fix(api): Automatic tip tracking index out of range fix (#15135) RQA-2700 Ensure automatic tip tracking for partial configurations does not exceed the limits of the tiprack it is iterating over --- .../opentrons/protocol_engine/state/tips.py | 14 ++- .../protocol_engine/state/test_tip_state.py | 119 ++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index f5d68d61ee5..5ff755ae0fb 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -297,7 +297,7 @@ def _cluster_search_A1(active_columns: int, active_rows: int) -> Optional[str]: critical_column = active_columns - 1 critical_row = active_rows - 1 - while critical_column <= len(columns): + while critical_column < len(columns): tip_cluster = _identify_tip_cluster( active_columns, active_rows, critical_column, critical_row, "A1" ) @@ -312,7 +312,7 @@ def _cluster_search_A1(active_columns: int, active_rows: int) -> Optional[str]: if critical_row + active_rows < len(columns[0]): critical_row = critical_row + active_rows else: - critical_column = critical_column + 1 + critical_column += 1 critical_row = active_rows - 1 return None @@ -336,7 +336,7 @@ def _cluster_search_A12(active_columns: int, active_rows: int) -> Optional[str]: if critical_row + active_rows < len(columns[0]): critical_row = critical_row + active_rows else: - critical_column = critical_column - 1 + critical_column -= 1 critical_row = active_rows - 1 return None @@ -360,7 +360,9 @@ def _cluster_search_H1(active_columns: int, active_rows: int) -> Optional[str]: if critical_row - active_rows >= 0: critical_row = critical_row - active_rows else: - critical_column = critical_column + 1 + critical_column += 1 + if critical_column >= len(columns): + return None critical_row = len(columns[critical_column]) - active_rows return None @@ -384,7 +386,9 @@ def _cluster_search_H12(active_columns: int, active_rows: int) -> Optional[str]: if critical_row - active_rows >= 0: critical_row = critical_row - active_rows else: - critical_column = critical_column - 1 + critical_column -= 1 + if critical_column < 0: + return None critical_row = len(columns[critical_column]) - active_rows return None diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index 51f78c4904d..b0eff46ff75 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -1135,3 +1135,122 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM _assert_and_pickup("H1", map) map = _reconfigure_nozzle_layout("A1", "A1", "A1") _assert_and_pickup("B9", map) + + +def test_next_tip_automatic_tip_tracking_tiprack_limits( + subject: TipStore, + supported_tip_fixture: pipette_definition.SupportedTipsDefinition, + load_labware_command: commands.LoadLabware, + pick_up_tip_command: commands.PickUpTip, +) -> None: + """Test tip tracking logic to ensure once a tiprack is consumed it returns None when consuming tips using multiple pipette configurations.""" + # Load labware + subject.handle_action( + actions.SucceedCommandAction(private_result=None, command=load_labware_command) + ) + + # Load pipette + load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] + result=commands.LoadPipetteResult(pipetteId="pipette-id") + ) + load_pipette_private_result = commands.LoadPipettePrivateResult( + pipette_id="pipette-id", + serial_number="pipette-serial", + config=LoadedStaticPipetteData( + channels=96, + max_volume=15, + min_volume=3, + model="gen a", + display_name="display name", + flow_rates=FlowRates( + default_aspirate={}, + default_dispense={}, + default_blow_out={}, + ), + tip_configuration_lookup_table={15: supported_tip_fixture}, + nominal_tip_overlap={}, + nozzle_offset_z=1.23, + home_position=4.56, + nozzle_map=get_default_nozzle_map(PipetteNameType.P1000_96), + back_left_corner_offset=Point(x=1, y=2, z=3), + front_right_corner_offset=Point(x=4, y=5, z=6), + ), + ) + subject.handle_action( + actions.SucceedCommandAction( + private_result=load_pipette_private_result, command=load_pipette_command + ) + ) + + def _get_next_and_pickup(nozzle_map: NozzleMap) -> str | None: + result = TipView(subject.state).get_next_tip( + labware_id="cool-labware", + num_tips=0, + starting_tip_name=None, + nozzle_map=nozzle_map, + ) + if result is not None: + pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] + params=commands.PickUpTipParams.construct( + pipetteId="pipette-id", + labwareId="cool-labware", + wellName=result, + ), + result=commands.PickUpTipResult.construct( + position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 + ), + ) + + subject.handle_action( + actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + ) + + return result + + # Configure nozzle for partial configurations + configure_nozzle_layout_cmd = commands.ConfigureNozzleLayout.construct( # type: ignore[call-arg] + result=commands.ConfigureNozzleLayoutResult() + ) + + def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleMap: + configure_nozzle_private_result = commands.ConfigureNozzleLayoutPrivateResult( + pipette_id="pipette-id", + nozzle_map=NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle=start, + back_left_nozzle=back_l, + front_right_nozzle=front_r, + ), + ) + subject.handle_action( + actions.SucceedCommandAction( + private_result=configure_nozzle_private_result, + command=configure_nozzle_layout_cmd, + ) + ) + return configure_nozzle_private_result.nozzle_map + + map = _reconfigure_nozzle_layout("A1", "A1", "A1") + for x in range(96): + _get_next_and_pickup(map) + assert _get_next_and_pickup(map) is None + + subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) + map = _reconfigure_nozzle_layout("A12", "A12", "A12") + for x in range(96): + _get_next_and_pickup(map) + assert _get_next_and_pickup(map) is None + + subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) + map = _reconfigure_nozzle_layout("H1", "H1", "H1") + for x in range(96): + _get_next_and_pickup(map) + assert _get_next_and_pickup(map) is None + + subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) + map = _reconfigure_nozzle_layout("H12", "H12", "H12") + for x in range(96): + _get_next_and_pickup(map) + assert _get_next_and_pickup(map) is None