diff --git a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py index 1440dfd70a8..8e00114747a 100644 --- a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -82,7 +82,7 @@ def module_helper( y = one_module["moduleOffset"]["offset"].get("y", "") z = one_module["moduleOffset"]["offset"].get("z", "") except KeyError: - pass + continue if mod_serial in module_sheet_serials and modified in module_modify_dates: continue module_row = ( diff --git a/analyses-snapshot-testing/citools/generate_analyses.py b/analyses-snapshot-testing/citools/generate_analyses.py index f67d0394429..52aba70363b 100644 --- a/analyses-snapshot-testing/citools/generate_analyses.py +++ b/analyses-snapshot-testing/citools/generate_analyses.py @@ -186,6 +186,7 @@ def analyze(protocol: TargetProtocol, container: docker.models.containers.Contai start_time = time.time() result = None exit_code = None + console.print(f"Beginning analysis of {protocol.host_protocol_file.name}") try: command_result = container.exec_run(cmd=command) exit_code = command_result.exit_code diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json index a73a19e4c88..a2aca7e252a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json @@ -9383,7 +9383,14 @@ }, "id": "UUID", "key": "08e16a2cac011d4bef561f8b0854d19e", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "4 custom tubes", "loadName": "cpx_4_tuberack_100ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json index 56d23e7468e..e4924262e1a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json @@ -9383,7 +9383,14 @@ }, "id": "UUID", "key": "08e16a2cac011d4bef561f8b0854d19e", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "4 custom tubes", "loadName": "cpx_4_tuberack_100ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json index 0ba775ce4cb..f2c63721b33 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json @@ -7895,7 +7895,14 @@ }, "id": "UUID", "key": "675eb8be-6c85-4204-9c87-d7fdd522f580", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "moduleId": "UUID" }, diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json index 99501a91cd3..0b2e524dee6 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json @@ -4341,7 +4341,14 @@ }, "id": "UUID", "key": "bccdb28e967f574dfbe472004101d7f9", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "Index Anchors", "loadName": "eppendorf_96_wellplate_150ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json index 9f6db189f50..b7c2e8d8c6a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json @@ -12587,7 +12587,14 @@ }, "id": "UUID", "key": "702caca4-12c8-4f26-a68e-138134723f09", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json index 77e30bb1865..351c26b64b4 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json @@ -1772,7 +1772,14 @@ }, "id": "UUID", "key": "c2e4fa67-3c04-4d22-b3fa-5d61e956c488", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "flowRate": 3.78, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json index 8986f5e49cb..5af5922dada 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json @@ -3035,7 +3035,14 @@ }, "id": "UUID", "key": "0bd3f36a944ee534e422ee69360a9501", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "flowRate": 7.56, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json index 60a3d0150e4..d810bd75c88 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json @@ -32,7 +32,14 @@ }, "id": "UUID", "key": "8511b05ba5565bf0e6dcccd800e2ee23", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C1" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json index 662d6cf0c4b..0aaa562c15c 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json @@ -1205,7 +1205,14 @@ }, "id": "UUID", "key": "2c37ad797da7df791b57a7843a203e88", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "A1", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json index 75ea09b454d..c76b2aca7f9 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json @@ -1181,7 +1181,14 @@ }, "id": "UUID", "key": "bd403a1c851a75b4b68ce34796d713fa", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "liquidPresenceDetection": false, "mount": "left", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json index 24e88e5454e..0de0eff0022 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json @@ -121,7 +121,14 @@ }, "id": "UUID", "key": "a3a7eed460d8d94a91747f23820a180d", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C3" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json index a59e4a3176f..2c3d142321b 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json @@ -2366,7 +2366,14 @@ }, "id": "UUID", "key": "4b1d27a6f17f312dd76668f0c48ed406", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "G12", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json index eedcd721687..8d4e3a960dd 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json @@ -1350,7 +1350,14 @@ }, "id": "UUID", "key": "4cca9753dc59d176eee1522349363a75", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json index 25cba8c59b8..ef9acd1b1a3 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json @@ -15103,7 +15103,14 @@ }, "id": "UUID", "key": "c3eacf39e9a35058cac9f69100549344", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "forceDirect": false, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json index d70c634dcc6..c8389b97d75 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json @@ -2351,7 +2351,14 @@ }, "id": "UUID", "key": "4b1d27a6f17f312dd76668f0c48ed406", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "A1", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json index e542e8191b2..7005e6011ab 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json @@ -4341,7 +4341,14 @@ }, "id": "UUID", "key": "bccdb28e967f574dfbe472004101d7f9", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "Index Anchors", "loadName": "eppendorf_96_wellplate_150ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json index 13e42d0bd8b..1149640d8b1 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json @@ -1220,7 +1220,14 @@ }, "id": "UUID", "key": "2c37ad797da7df791b57a7843a203e88", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "G12", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json index 2de55429a53..b22e56cb8ed 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json @@ -39047,7 +39047,14 @@ }, "id": "UUID", "key": "f524340032354f66bf69110d530e98ad", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json index 5c1d9a41364..368bbe05d9b 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json @@ -32,7 +32,14 @@ }, "id": "UUID", "key": "8511b05ba5565bf0e6dcccd800e2ee23", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C2" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json index 6e5b9d8028b..d1feceae4d0 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json @@ -17824,7 +17824,14 @@ }, "id": "UUID", "key": "7e96139ed2163fa7f870805d0b3d14b6", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "forceDirect": false, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json index 9fbcd62f394..d452cf7ab52 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json @@ -1255,7 +1255,14 @@ }, "id": "UUID", "key": "c55807b45b6b1d4ea04e12b0ee553f78", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "D3" diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 9a34c2b4978..621443dce03 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -8,6 +8,7 @@ import type { RunTimeCommand, RunTimeParameter, NozzleLayoutConfig, + OnDeckLabwareLocation, } from '@opentrons/shared-data' import type { ResourceLink, ErrorDetails } from '../types' export * from './commands/types' @@ -117,7 +118,9 @@ export interface Runs { } export interface RunCurrentStateData { + estopEngaged: boolean activeNozzleLayouts: Record // keyed by pipetteId + placeLabwareState?: PlaceLabwareState } export const RUN_ACTION_TYPE_PLAY: 'play' = 'play' @@ -209,3 +212,9 @@ export interface NozzleLayoutValues { activeNozzles: string[] config: NozzleLayoutConfig } + +export interface PlaceLabwareState { + labwareId: string + location: OnDeckLabwareLocation + shouldPlaceDown: boolean +} diff --git a/api/release-notes.md b/api/release-notes.md index 2a528502bef..e41d415d83e 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -8,6 +8,24 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons Robot Software Changes in 8.2.0 + +Welcome to the v8.2.0 release of the Opentrons robot software! This release adds support for the Opentrons Absorbance Plate Reader Module. + +### New Features + +- Create and run Python protocols that use the Opentrons Absorbance Plate Reader. + +### Improved Features + +- Liquid presence detection no longer checks for liquid before every aspiration in a `mix()` command. + +### Bug Fixes + +- Error recovery no longer causes an `AssertionError` when a Python protocol changes the pipette speed. + +--- + ## Opentrons Robot Software Changes in 8.1.0 Welcome to the v8.1.0 release of the Opentrons robot software! diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index c1389ea6a5b..931c99fd4c6 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -13,7 +13,6 @@ Sequence, Iterator, TypeVar, - overload, ) import numpy @@ -503,25 +502,12 @@ def plunger_flowrate( ul_per_s = mm_per_s * instr.ul_per_mm(instr.liquid_class.max_volume, action) return round(ul_per_s, 6) - @overload def plan_check_aspirate( - self, mount: top_types.Mount, volume: Optional[float], rate: float - ) -> Optional[LiquidActionSpec]: - ... - - @overload - def plan_check_aspirate( - self, mount: OT3Mount, volume: Optional[float], rate: float - ) -> Optional[LiquidActionSpec]: - ... - - # note on this type ignore: see motion_utilities - def plan_check_aspirate( # type: ignore[no-untyped-def] self, - mount, - volume, - rate, - ): + mount: MountType, + volume: Optional[float], + rate: float, + ) -> Optional[LiquidActionSpec]: """Check preconditions for aspirate, parse args, and calculate positions. While the mechanics of issuing an aspirate move itself are left to child @@ -580,28 +566,12 @@ def plan_check_aspirate( # type: ignore[no-untyped-def] current=instrument.plunger_motor_current.run, ) - @overload def plan_check_dispense( self, - mount: top_types.Mount, - volume: Optional[float], - rate: float, - push_out: Optional[float], - ) -> Optional[LiquidActionSpec]: - ... - - @overload - def plan_check_dispense( - self, - mount: OT3Mount, + mount: MountType, volume: Optional[float], rate: float, push_out: Optional[float], - ) -> Optional[LiquidActionSpec]: - ... - - def plan_check_dispense( # type: ignore[no-untyped-def] - self, mount, volume, rate, push_out ) -> Optional[LiquidActionSpec]: """Check preconditions for dispense, parse args, and calculate positions. @@ -695,15 +665,7 @@ def plan_check_dispense( # type: ignore[no-untyped-def] current=instrument.plunger_motor_current.run, ) - @overload - def plan_check_blow_out(self, mount: top_types.Mount) -> LiquidActionSpec: - ... - - @overload - def plan_check_blow_out(self, mount: OT3Mount) -> LiquidActionSpec: - ... - - def plan_check_blow_out(self, mount): # type: ignore[no-untyped-def] + def plan_check_blow_out(self, mount: MountType) -> LiquidActionSpec: """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) speed = self.plunger_speed( @@ -751,33 +713,13 @@ def build_one_shake() -> List[Tuple[top_types.Point, Optional[float]]]: else: return [] - @overload def plan_check_pick_up_tip( self, - mount: top_types.Mount, - presses: Optional[int], - increment: Optional[float], - tip_length: float = 0, - ) -> Tuple[PickUpTipSpec, Callable[[], None]]: - ... - - @overload - def plan_check_pick_up_tip( - self, - mount: OT3Mount, + mount: MountType, presses: Optional[int], increment: Optional[float], tip_length: float = 0, ) -> Tuple[PickUpTipSpec, Callable[[], None]]: - ... - - def plan_check_pick_up_tip( # type: ignore[no-untyped-def] - self, - mount, - presses, - increment, - tip_length=0, - ): # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: @@ -925,25 +867,13 @@ def build() -> List[DropTipMove]: return build - @overload - def plan_check_drop_tip( - self, mount: top_types.Mount, home_after: bool - ) -> Tuple[DropTipSpec, Callable[[], None]]: - ... - - @overload - def plan_check_drop_tip( - self, mount: OT3Mount, home_after: bool - ) -> Tuple[DropTipSpec, Callable[[], None]]: - ... - # todo(mm, 2024-10-17): The returned _remove_tips() callable is not used by anything # anymore. Delete it. - def plan_check_drop_tip( # type: ignore[no-untyped-def] + def plan_check_drop_tip( self, - mount, - home_after, - ): + mount: MountType, + home_after: bool, + ) -> Tuple[DropTipSpec, Callable[[], None]]: instrument = self.get_pipette(mount) if not instrument.drop_configurations.plunger_eject: diff --git a/api/src/opentrons/hardware_control/modules/absorbance_reader.py b/api/src/opentrons/hardware_control/modules/absorbance_reader.py index da7c4746086..ab6ce1bb22b 100644 --- a/api/src/opentrons/hardware_control/modules/absorbance_reader.py +++ b/api/src/opentrons/hardware_control/modules/absorbance_reader.py @@ -272,12 +272,8 @@ def usb_port(self) -> USBPort: return self._usb_port async def deactivate(self, must_be_running: bool = True) -> None: - """Deactivate the module. - - Contains an override to the `wait_for_is_running` step in cases where the - module must be deactivated regardless of context.""" - await self._poller.stop() - await self._driver.disconnect() + """Deactivate the module.""" + pass async def wait_for_is_running(self) -> None: if not self.is_simulated: @@ -336,7 +332,8 @@ async def cleanup(self) -> None: Clean up, i.e. stop pollers, disconnect serial, etc in preparation for object destruction. """ - await self.deactivate() + await self._poller.stop() + await self._driver.disconnect() async def set_sample_wavelength( self, diff --git a/api/src/opentrons/motion_planning/__init__.py b/api/src/opentrons/motion_planning/__init__.py index 570d4250ebe..2b304ecb74d 100644 --- a/api/src/opentrons/motion_planning/__init__.py +++ b/api/src/opentrons/motion_planning/__init__.py @@ -6,6 +6,7 @@ MINIMUM_Z_MARGIN, get_waypoints, get_gripper_labware_movement_waypoints, + get_gripper_labware_placement_waypoints, ) from .types import Waypoint, MoveType @@ -27,4 +28,5 @@ "ArcOutOfBoundsError", "get_waypoints", "get_gripper_labware_movement_waypoints", + "get_gripper_labware_placement_waypoints", ] diff --git a/api/src/opentrons/motion_planning/waypoints.py b/api/src/opentrons/motion_planning/waypoints.py index b9c62114215..bcc56ad7eda 100644 --- a/api/src/opentrons/motion_planning/waypoints.py +++ b/api/src/opentrons/motion_planning/waypoints.py @@ -181,3 +181,35 @@ def get_gripper_labware_movement_waypoints( ) ) return waypoints_with_jaw_status + + +def get_gripper_labware_placement_waypoints( + to_labware_center: Point, + gripper_home_z: float, + drop_offset: Optional[Point], +) -> List[GripperMovementWaypointsWithJawStatus]: + """Get waypoints for placing labware using a gripper.""" + drop_offset = drop_offset or Point() + + drop_location = to_labware_center + Point( + drop_offset.x, drop_offset.y, drop_offset.z + ) + + post_drop_home_pos = Point(drop_location.x, drop_location.y, gripper_home_z) + + return [ + GripperMovementWaypointsWithJawStatus( + position=Point(drop_location.x, drop_location.y, gripper_home_z), + jaw_open=False, + dropping=False, + ), + GripperMovementWaypointsWithJawStatus( + position=drop_location, jaw_open=False, dropping=False + ), + # Gripper ungrips here + GripperMovementWaypointsWithJawStatus( + position=post_drop_home_pos, + jaw_open=True, + dropping=True, + ), + ] diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 4474a174a85..825d45bfded 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -153,6 +153,8 @@ def aspirate( absolute_point=location.point, is_meniscus=is_meniscus, ) + if well_location.origin == WellOrigin.MENISCUS: + well_location.volumeOffset = "operationVolume" pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 1d800dee7ea..1e6d4e26b2f 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -567,6 +567,7 @@ class AbsorbanceReaderCore(ModuleCore, AbstractAbsorbanceReaderCore): _sync_module_hardware: SynchronousAdapter[hw_modules.AbsorbanceReader] _initialized_value: Optional[List[int]] = None + _ready_to_initialize: bool = False def initialize( self, @@ -575,6 +576,11 @@ def initialize( reference_wavelength: Optional[int] = None, ) -> None: """Initialize the Absorbance Reader by taking zero reading.""" + if not self._ready_to_initialize: + raise CannotPerformModuleAction( + "Cannot perform Initialize action on Absorbance Reader without calling `.close_lid()` first." + ) + # TODO: check that the wavelengths are within the supported wavelengths self._engine_client.execute_command( cmd.absorbance_reader.InitializeParams( @@ -586,7 +592,7 @@ def initialize( ) self._initialized_value = wavelengths - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return a measurement of zero for all wells.""" wavelengths = self._engine_client.state.modules.get_absorbance_reader_substate( self.module_id @@ -633,6 +639,7 @@ def close_lid( moduleId=self.module_id, ) ) + self._ready_to_initialize = True def open_lid(self) -> None: """Close the Absorbance Reader's lid.""" diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 0ed5270320a..dac8bc44a5b 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -449,9 +449,10 @@ def load_module( # When the protocol engine is created, we add Module Lids as part of the deck fixed labware # If a valid module exists in the deck config. For analysis, we add the labware here since - # deck fixed labware is not created under the same conditions. - if self._engine_client.state.config.use_virtual_modules: - self._load_virtual_module_lid(module_core) + # deck fixed labware is not created under the same conditions. We also need to inject the Module + # lids when the module isnt already on the deck config, like when adding a new + # module during a protocol setup. + self._load_virtual_module_lid(module_core) self._module_cores_by_id[module_core.module_id] = module_core @@ -461,20 +462,24 @@ def _load_virtual_module_lid( self, module_core: Union[ModuleCore, NonConnectedModuleCore] ) -> None: if isinstance(module_core, AbsorbanceReaderCore): - lid = self._engine_client.execute_command_without_recovery( - cmd.LoadLabwareParams( - loadName="opentrons_flex_lid_absorbance_plate_reader_module", - location=ModuleLocation(moduleId=module_core.module_id), - namespace="opentrons", - version=1, - displayName="Absorbance Reader Lid", - ) + substate = self._engine_client.state.modules.get_absorbance_reader_substate( + module_core.module_id ) + if substate.lid_id is None: + lid = self._engine_client.execute_command_without_recovery( + cmd.LoadLabwareParams( + loadName="opentrons_flex_lid_absorbance_plate_reader_module", + location=ModuleLocation(moduleId=module_core.module_id), + namespace="opentrons", + version=1, + displayName="Absorbance Reader Lid", + ) + ) - self._engine_client.add_absorbance_reader_lid( - module_id=module_core.module_id, - lid_id=lid.labwareId, - ) + self._engine_client.add_absorbance_reader_lid( + module_id=module_core.module_id, + lid_id=lid.labwareId, + ) def _create_non_connected_module_core( self, load_module_result: LoadModuleResult diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index c93e8ce8de8..e24fbbc54b0 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -365,7 +365,7 @@ def initialize( """Initialize the Absorbance Reader by taking zero reading.""" @abstractmethod - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Get an absorbance reading from the Absorbance Reader.""" @abstractmethod diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index f7541da1836..9ae550f8d3f 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -1035,7 +1035,9 @@ def initialize( ) @requires_version(2, 21) - def read(self, export_filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read( + self, export_filename: Optional[str] = None + ) -> Dict[int, Dict[str, float]]: """Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name. diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index dfd497817c0..26dfb0df8e0 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -30,7 +30,7 @@ SetPipetteMovementSpeedAction, AddAbsorbanceReaderLidAction, ) -from .get_state_update import get_state_update +from .get_state_update import get_state_updates __all__ = [ # action pipeline interface @@ -63,5 +63,5 @@ "PauseSource", "FinishErrorDetails", # helper functions - "get_state_update", + "get_state_updates", ] diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 4569f7866ef..6260a6d4614 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -18,7 +18,6 @@ Command, CommandCreate, CommandDefinedErrorData, - CommandPrivateResult, ) from ..error_recovery_policy import ErrorRecoveryPolicy, ErrorRecoveryType from ..notes.notes import CommandNote @@ -69,7 +68,7 @@ class StopAction: class ResumeFromRecoveryAction: """See `ProtocolEngine.resume_from_recovery()`.""" - pass + state_update: StateUpdate @dataclasses.dataclass(frozen=True) @@ -146,10 +145,6 @@ class SucceedCommandAction: command: Command """The command in its new succeeded state.""" - # todo(mm, 2024-08-26): Remove when no state stores use this anymore. - # https://opentrons.atlassian.net/browse/EXEC-639 - private_result: CommandPrivateResult - state_update: StateUpdate = dataclasses.field( # todo(mm, 2024-08-26): This has a default only to make it easier to transition # old tests while https://opentrons.atlassian.net/browse/EXEC-639 is in diff --git a/api/src/opentrons/protocol_engine/actions/get_state_update.py b/api/src/opentrons/protocol_engine/actions/get_state_update.py index e0ddadc3222..ec29a6e38ef 100644 --- a/api/src/opentrons/protocol_engine/actions/get_state_update.py +++ b/api/src/opentrons/protocol_engine/actions/get_state_update.py @@ -1,18 +1,38 @@ # noqa: D100 +from __future__ import annotations +from typing import TYPE_CHECKING - -from .actions import Action, SucceedCommandAction, FailCommandAction +from .actions import ( + Action, + ResumeFromRecoveryAction, + SucceedCommandAction, + FailCommandAction, +) from ..commands.command import DefinedErrorData -from ..state.update_types import StateUpdate +from ..error_recovery_policy import ErrorRecoveryType + +if TYPE_CHECKING: + from ..state.update_types import StateUpdate -def get_state_update(action: Action) -> StateUpdate | None: - """Extract the StateUpdate from an action, if there is one.""" +def get_state_updates(action: Action) -> list[StateUpdate]: + """Extract all the StateUpdates that the StateStores should apply when they apply an action.""" if isinstance(action, SucceedCommandAction): - return action.state_update + return [action.state_update] + elif isinstance(action, FailCommandAction) and isinstance( action.error, DefinedErrorData ): - return action.error.state_update + if action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE: + return [ + action.error.state_update, + action.error.state_update_if_false_positive, + ] + else: + return [action.error.state_update] + + elif isinstance(action, ResumeFromRecoveryAction): + return [action.state_update] + else: - return None + return [] diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index b8ad7ab0b57..649bb4b6507 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -38,7 +38,6 @@ CommandCreate, CommandResult, CommandType, - CommandPrivateResult, CommandDefinedErrorData, ) @@ -153,7 +152,6 @@ LoadPipetteCreate, LoadPipetteResult, LoadPipetteCommandType, - LoadPipettePrivateResult, ) from .move_labware import ( diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py index b5131d76bcf..2f7f96d9523 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -37,9 +37,7 @@ class CloseLidResult(BaseModel): """Result data from closing the lid on an aborbance reading.""" -class CloseLidImpl( - AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult, None]] -): +class CloseLidImpl(AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult]]): """Execution implementation of closing the lid on an Absorbance Reader.""" def __init__( @@ -53,9 +51,7 @@ def __init__( self._equipment = equipment self._labware_movement = labware_movement - async def execute( - self, params: CloseLidParams - ) -> SuccessData[CloseLidResult, None]: + async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Execute the close lid command.""" mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -142,7 +138,6 @@ async def execute( return SuccessData( public=CloseLidResult(), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py index 314645b39b2..4b28154ed17 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py @@ -37,7 +37,7 @@ class InitializeResult(BaseModel): class InitializeImpl( - AbstractCommandImpl[InitializeParams, SuccessData[InitializeResult, None]] + AbstractCommandImpl[InitializeParams, SuccessData[InitializeResult]] ): """Execution implementation of initializing an Absorbance Reader.""" @@ -50,9 +50,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: InitializeParams - ) -> SuccessData[InitializeResult, None]: + async def execute(self, params: InitializeParams) -> SuccessData[InitializeResult]: """Initiate a single absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -105,7 +103,6 @@ async def execute( return SuccessData( public=InitializeResult(), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py index 7a048a69b52..5f3eed57199 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -38,7 +38,7 @@ class OpenLidResult(BaseModel): """Result data from opening the lid on an aborbance reading.""" -class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult, None]]): +class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult]]): """Execution implementation of opening the lid on an Absorbance Reader.""" def __init__( @@ -52,7 +52,7 @@ def __init__( self._equipment = equipment self._labware_movement = labware_movement - async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, None]: + async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Move the absorbance reader lid from the module to the lid dock.""" mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -137,7 +137,6 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, Non return SuccessData( public=OpenLidResult(), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py index caf8a738f09..8743fd1383b 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py @@ -48,7 +48,7 @@ class ReadAbsorbanceResult(BaseModel): class ReadAbsorbanceImpl( - AbstractCommandImpl[ReadAbsorbanceParams, SuccessData[ReadAbsorbanceResult, None]] + AbstractCommandImpl[ReadAbsorbanceParams, SuccessData[ReadAbsorbanceResult]] ): """Execution implementation of an Absorbance Reader measurement.""" @@ -65,7 +65,7 @@ def __init__( async def execute( # noqa: C901 self, params: ReadAbsorbanceParams - ) -> SuccessData[ReadAbsorbanceResult, None]: + ) -> SuccessData[ReadAbsorbanceResult]: """Initiate an absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -168,12 +168,10 @@ async def execute( # noqa: C901 public=ReadAbsorbanceResult( data=asbsorbance_result, fileIds=file_ids ), - private=None, ) return SuccessData( public=ReadAbsorbanceResult(data=asbsorbance_result, fileIds=file_ids), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 14b59248216..00d57a93e9a 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -24,7 +24,7 @@ from opentrons.hardware_control import HardwareControlAPI -from ..state.update_types import StateUpdate +from ..state.update_types import StateUpdate, CLEAR from ..types import WellLocation, WellOrigin, CurrentWell, DeckPoint if TYPE_CHECKING: @@ -52,7 +52,7 @@ class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[AspirateResult, None], + SuccessData[AspirateResult], DefinedErrorData[OverpressureError], ] @@ -112,15 +112,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_name=well_name, ) - well_location = params.wellLocation - if well_location.origin == WellOrigin.MENISCUS: - well_location.volumeOffset = "operationVolume" - position = await self._movement.move_to_well( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name, - well_location=well_location, + well_location=params.wellLocation, current_well=current_well, operation_volume=-params.volume, ) @@ -140,6 +136,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -156,12 +157,16 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=-volume_aspirated, + ) return SuccessData( public=AspirateResult( volume=volume_aspirated, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py index 59879e7ca63..4c7ab2cc01c 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py @@ -24,6 +24,8 @@ ) from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -47,7 +49,7 @@ class AspirateInPlaceResult(BaseLiquidHandlingResult): _ExecuteReturn = Union[ - SuccessData[AspirateInPlaceResult, None], + SuccessData[AspirateInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -91,6 +93,10 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: " The first aspirate following a blow-out must be from a specific well" " so the plunger can be reset in a known safe position." ) + + state_update = StateUpdate() + current_location = self._state_view.pipettes.get_current_location() + try: current_position = await self._gantry_mover.get_position(params.pipetteId) volume = await self._pipetting.aspirate_in_place( @@ -100,6 +106,15 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -121,10 +136,21 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=-volume, + ) return SuccessData( - public=AspirateInPlaceResult(volume=volume), private=None + public=AspirateInPlaceResult(volume=volume), + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/blow_out.py b/api/src/opentrons/protocol_engine/commands/blow_out.py index c8a6b65ce63..e13378b5541 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out.py @@ -48,7 +48,7 @@ class BlowOutResult(DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[BlowOutResult, None], + SuccessData[BlowOutResult], DefinedErrorData[OverpressureError], ] @@ -116,7 +116,6 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn: else: return SuccessData( public=BlowOutResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py index 38165a4a626..0b9aaec77b2 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py @@ -45,7 +45,7 @@ class BlowOutInPlaceResult(BaseModel): _ExecuteReturn = Union[ - SuccessData[BlowOutInPlaceResult, None], + SuccessData[BlowOutInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -99,7 +99,9 @@ async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn: ), ) else: - return SuccessData(public=BlowOutInPlaceResult(), private=None) + return SuccessData( + public=BlowOutInPlaceResult(), + ) class BlowOutInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py index b400e2dd33a..0333a171077 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py @@ -71,9 +71,7 @@ class CalibrateGripperResult(BaseModel): class CalibrateGripperImplementation( - AbstractCommandImpl[ - CalibrateGripperParams, SuccessData[CalibrateGripperResult, None] - ] + AbstractCommandImpl[CalibrateGripperParams, SuccessData[CalibrateGripperResult]] ): """The implementation of a `calibrateGripper` command.""" @@ -87,7 +85,7 @@ def __init__( async def execute( self, params: CalibrateGripperParams - ) -> SuccessData[CalibrateGripperResult, None]: + ) -> SuccessData[CalibrateGripperResult]: """Execute a `calibrateGripper` command. 1. Move from the current location to the calibration area on the deck. @@ -126,7 +124,6 @@ async def execute( ), savedCalibration=calibration_data, ), - private=None, ) @staticmethod diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py index 8eee75c6207..f488e8eab97 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py @@ -49,7 +49,7 @@ class CalibrateModuleResult(BaseModel): class CalibrateModuleImplementation( - AbstractCommandImpl[CalibrateModuleParams, SuccessData[CalibrateModuleResult, None]] + AbstractCommandImpl[CalibrateModuleParams, SuccessData[CalibrateModuleResult]] ): """CalibrateModule command implementation.""" @@ -64,7 +64,7 @@ def __init__( async def execute( self, params: CalibrateModuleParams - ) -> SuccessData[CalibrateModuleResult, None]: + ) -> SuccessData[CalibrateModuleResult]: """Execute calibrate-module command.""" ot3_api = ensure_ot3_hardware( self._hardware_api, @@ -91,7 +91,6 @@ async def execute( ), location=slot, ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py index 4369f88a9c5..fbe754f6389 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py @@ -34,9 +34,7 @@ class CalibratePipetteResult(BaseModel): class CalibratePipetteImplementation( - AbstractCommandImpl[ - CalibratePipetteParams, SuccessData[CalibratePipetteResult, None] - ] + AbstractCommandImpl[CalibratePipetteParams, SuccessData[CalibratePipetteResult]] ): """CalibratePipette command implementation.""" @@ -49,7 +47,7 @@ def __init__( async def execute( self, params: CalibratePipetteParams - ) -> SuccessData[CalibratePipetteResult, None]: + ) -> SuccessData[CalibratePipetteResult]: """Execute calibrate-pipette command.""" # TODO (tz, 20-9-22): Add a better solution to determine if a command can be executed on an OT-3/OT-2 ot3_api = ensure_ot3_hardware( @@ -72,7 +70,6 @@ async def execute( x=pipette_offset.x, y=pipette_offset.y, z=pipette_offset.z ) ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py index 73a0a8c2511..afb178cae99 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py @@ -57,7 +57,7 @@ class MoveToMaintenancePositionResult(BaseModel): class MoveToMaintenancePositionImplementation( AbstractCommandImpl[ MoveToMaintenancePositionParams, - SuccessData[MoveToMaintenancePositionResult, None], + SuccessData[MoveToMaintenancePositionResult], ] ): """Calibration set up position command implementation.""" @@ -73,7 +73,7 @@ def __init__( async def execute( self, params: MoveToMaintenancePositionParams - ) -> SuccessData[MoveToMaintenancePositionResult, None]: + ) -> SuccessData[MoveToMaintenancePositionResult]: """Move the requested mount to a maintenance deck slot.""" ot3_api = ensure_ot3_hardware( self._hardware_api, @@ -118,7 +118,9 @@ async def execute( ) await ot3_api.disengage_axes([Axis.Z_R]) - return SuccessData(public=MoveToMaintenancePositionResult(), private=None) + return SuccessData( + public=MoveToMaintenancePositionResult(), + ) class MoveToMaintenancePosition( diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 9ba9404af1f..1fefcbf7315 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -39,7 +39,6 @@ _ResultT_co = TypeVar("_ResultT_co", bound=BaseModel, covariant=True) _ErrorT = TypeVar("_ErrorT", bound=ErrorOccurrence) _ErrorT_co = TypeVar("_ErrorT_co", bound=ErrorOccurrence, covariant=True) -_PrivateResultT_co = TypeVar("_PrivateResultT_co", covariant=True) class CommandStatus(str, Enum): @@ -108,19 +107,12 @@ class BaseCommandCreate( @dataclasses.dataclass(frozen=True) -class SuccessData(Generic[_ResultT_co, _PrivateResultT_co]): +class SuccessData(Generic[_ResultT_co]): """Data from the successful completion of a command.""" public: _ResultT_co """Public result data. Exposed over HTTP and stored in databases.""" - private: _PrivateResultT_co - """Additional result data, only given to `opentrons.protocol_engine` internals. - - Deprecated: - Use `state_update` instead. - """ - state_update: StateUpdate = dataclasses.field( # todo(mm, 2024-08-22): Remove the default once all command implementations # use this, to make it harder to forget in new command implementations. @@ -147,6 +139,10 @@ class DefinedErrorData(Generic[_ErrorT_co]): ) """How the engine state should be updated to reflect this command failure.""" + state_update_if_false_positive: StateUpdate = dataclasses.field( + default_factory=StateUpdate + ) + class BaseCommand( GenericModel, @@ -235,8 +231,6 @@ class BaseCommand( # Our _ImplementationCls must return public result data that can fit # in our `result` field: _ResultT, - # But we don't care (here) what kind of private result data it returns: - object, ], DefinedErrorData[ # Our _ImplementationCls must return public error data that can fit @@ -251,7 +245,7 @@ class BaseCommand( _ExecuteReturnT_co = TypeVar( "_ExecuteReturnT_co", bound=Union[ - SuccessData[BaseModel, object], + SuccessData[BaseModel], DefinedErrorData[ErrorOccurrence], ], covariant=True, diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 7623cc09f68..0e0a4cf3112 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -141,7 +141,6 @@ LoadPipetteCreate, LoadPipetteResult, LoadPipetteCommandType, - LoadPipettePrivateResult, ) from .move_labware import ( @@ -272,7 +271,6 @@ ConfigureForVolumeCreate, ConfigureForVolumeResult, ConfigureForVolumeCommandType, - ConfigureForVolumePrivateResult, ) from .prepare_to_aspirate import ( @@ -394,6 +392,7 @@ unsafe.UpdatePositionEstimators, unsafe.UnsafeEngageAxes, unsafe.UnsafeUngripLabware, + unsafe.UnsafePlaceLabware, ], Field(discriminator="commandType"), ] @@ -471,6 +470,7 @@ unsafe.UpdatePositionEstimatorsParams, unsafe.UnsafeEngageAxesParams, unsafe.UnsafeUngripLabwareParams, + unsafe.UnsafePlaceLabwareParams, ] CommandType = Union[ @@ -546,6 +546,7 @@ unsafe.UpdatePositionEstimatorsCommandType, unsafe.UnsafeEngageAxesCommandType, unsafe.UnsafeUngripLabwareCommandType, + unsafe.UnsafePlaceLabwareCommandType, ] CommandCreate = Annotated[ @@ -622,6 +623,7 @@ unsafe.UpdatePositionEstimatorsCreate, unsafe.UnsafeEngageAxesCreate, unsafe.UnsafeUngripLabwareCreate, + unsafe.UnsafePlaceLabwareCreate, ], Field(discriminator="commandType"), ] @@ -699,17 +701,9 @@ unsafe.UpdatePositionEstimatorsResult, unsafe.UnsafeEngageAxesResult, unsafe.UnsafeUngripLabwareResult, + unsafe.UnsafePlaceLabwareResult, ] -# todo(mm, 2024-06-12): Ideally, command return types would have specific -# CommandPrivateResults paired with specific CommandResults. For example, -# a TouchTipResult can never be paired with a LoadPipettePrivateResult in practice, -# and ideally our types would reflect that. -CommandPrivateResult = Union[ - None, - LoadPipettePrivateResult, - ConfigureForVolumePrivateResult, -] # All `DefinedErrorData`s that implementations will actually return in practice. CommandDefinedErrorData = Union[ diff --git a/api/src/opentrons/protocol_engine/commands/comment.py b/api/src/opentrons/protocol_engine/commands/comment.py index d411b6b4047..5cd0b0c3113 100644 --- a/api/src/opentrons/protocol_engine/commands/comment.py +++ b/api/src/opentrons/protocol_engine/commands/comment.py @@ -24,16 +24,18 @@ class CommentResult(BaseModel): class CommentImplementation( - AbstractCommandImpl[CommentParams, SuccessData[CommentResult, None]] + AbstractCommandImpl[CommentParams, SuccessData[CommentResult]] ): """Comment command implementation.""" def __init__(self, **kwargs: object) -> None: pass - async def execute(self, params: CommentParams) -> SuccessData[CommentResult, None]: + async def execute(self, params: CommentParams) -> SuccessData[CommentResult]: """No operation taken other than capturing message in command.""" - return SuccessData(public=CommentResult(), private=None) + return SuccessData( + public=CommentResult(), + ) class Comment(BaseCommand[CommentParams, CommentResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/configure_for_volume.py b/api/src/opentrons/protocol_engine/commands/configure_for_volume.py index 93a56ca8805..1c8aa21f491 100644 --- a/api/src/opentrons/protocol_engine/commands/configure_for_volume.py +++ b/api/src/opentrons/protocol_engine/commands/configure_for_volume.py @@ -7,7 +7,6 @@ from .pipetting_common import PipetteIdMixin from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence -from .configuring_common import PipetteConfigUpdateResultMixin from ..state.update_types import StateUpdate if TYPE_CHECKING: @@ -35,12 +34,6 @@ class ConfigureForVolumeParams(PipetteIdMixin): ) -class ConfigureForVolumePrivateResult(PipetteConfigUpdateResultMixin): - """Result sent to the store but not serialized.""" - - pass - - class ConfigureForVolumeResult(BaseModel): """Result data from execution of an ConfigureForVolume command.""" @@ -50,7 +43,7 @@ class ConfigureForVolumeResult(BaseModel): class ConfigureForVolumeImplementation( AbstractCommandImpl[ ConfigureForVolumeParams, - SuccessData[ConfigureForVolumeResult, ConfigureForVolumePrivateResult], + SuccessData[ConfigureForVolumeResult], ] ): """Configure for volume command implementation.""" @@ -60,7 +53,7 @@ def __init__(self, equipment: EquipmentHandler, **kwargs: object) -> None: async def execute( self, params: ConfigureForVolumeParams - ) -> SuccessData[ConfigureForVolumeResult, ConfigureForVolumePrivateResult]: + ) -> SuccessData[ConfigureForVolumeResult]: """Check that requested pipette can be configured for the given volume.""" pipette_result = await self._equipment.configure_for_volume( pipette_id=params.pipetteId, @@ -77,11 +70,6 @@ async def execute( return SuccessData( public=ConfigureForVolumeResult(), - private=ConfigureForVolumePrivateResult( - pipette_id=pipette_result.pipette_id, - serial_number=pipette_result.serial_number, - config=pipette_result.static_config, - ), state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py index d04eee55c94..f78839773ec 100644 --- a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py +++ b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py @@ -46,7 +46,7 @@ class ConfigureNozzleLayoutResult(BaseModel): class ConfigureNozzleLayoutImplementation( AbstractCommandImpl[ ConfigureNozzleLayoutParams, - SuccessData[ConfigureNozzleLayoutResult, None], + SuccessData[ConfigureNozzleLayoutResult], ] ): """Configure nozzle layout command implementation.""" @@ -59,7 +59,7 @@ def __init__( async def execute( self, params: ConfigureNozzleLayoutParams - ) -> SuccessData[ConfigureNozzleLayoutResult, None]: + ) -> SuccessData[ConfigureNozzleLayoutResult]: """Check that requested pipette can support the requested nozzle layout.""" primary_nozzle = params.configurationParams.dict().get("primaryNozzle") front_right_nozzle = params.configurationParams.dict().get("frontRightNozzle") @@ -84,7 +84,6 @@ async def execute( return SuccessData( public=ConfigureNozzleLayoutResult(), - private=None, state_update=update_state, ) diff --git a/api/src/opentrons/protocol_engine/commands/configuring_common.py b/api/src/opentrons/protocol_engine/commands/configuring_common.py deleted file mode 100644 index f69cf41fef6..00000000000 --- a/api/src/opentrons/protocol_engine/commands/configuring_common.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Common configuration command base models.""" - -from dataclasses import dataclass -from ..resources import pipette_data_provider - - -@dataclass -class PipetteConfigUpdateResultMixin: - """A mixin-suitable model for adding pipette config to private results.""" - - pipette_id: str - serial_number: str - config: pipette_data_provider.LoadedStaticPipetteData diff --git a/api/src/opentrons/protocol_engine/commands/custom.py b/api/src/opentrons/protocol_engine/commands/custom.py index 2ceebda764c..1bdf07084be 100644 --- a/api/src/opentrons/protocol_engine/commands/custom.py +++ b/api/src/opentrons/protocol_engine/commands/custom.py @@ -40,16 +40,18 @@ class Config: class CustomImplementation( - AbstractCommandImpl[CustomParams, SuccessData[CustomResult, None]] + AbstractCommandImpl[CustomParams, SuccessData[CustomResult]] ): """Custom command implementation.""" # TODO(mm, 2022-11-09): figure out how a plugin can specify a custom command # implementation. For now, always no-op, so we can use custom commands as containers # for legacy RPC (pre-ProtocolEngine) payloads. - async def execute(self, params: CustomParams) -> SuccessData[CustomResult, None]: + async def execute(self, params: CustomParams) -> SuccessData[CustomResult]: """A custom command does nothing when executed directly.""" - return SuccessData(public=CustomResult.construct(), private=None) + return SuccessData( + public=CustomResult.construct(), + ) class Custom(BaseCommand[CustomParams, CustomResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 7e18cc6560b..a7fee20c762 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -8,7 +8,7 @@ from pydantic import Field from ..types import DeckPoint -from ..state.update_types import StateUpdate +from ..state.update_types import StateUpdate, CLEAR from .pipetting_common import ( PipetteIdMixin, DispenseVolumeMixin, @@ -54,7 +54,7 @@ class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[DispenseResult, None], + SuccessData[DispenseResult], DefinedErrorData[OverpressureError], ] @@ -107,6 +107,11 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -123,9 +128,13 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=volume, + ) return SuccessData( public=DispenseResult(volume=volume, position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py index 36f15e8e528..7df9471b038 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -21,10 +21,13 @@ DefinedErrorData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover from ..resources import ModelUtils + from ..state.state import StateView DispenseInPlaceCommandType = Literal["dispenseInPlace"] @@ -46,7 +49,7 @@ class DispenseInPlaceResult(BaseLiquidHandlingResult): _ExecuteReturn = Union[ - SuccessData[DispenseInPlaceResult, None], + SuccessData[DispenseInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -59,16 +62,20 @@ class DispenseInPlaceImplementation( def __init__( self, pipetting: PipettingHandler, + state_view: StateView, gantry_mover: GantryMover, model_utils: ModelUtils, **kwargs: object, ) -> None: self._pipetting = pipetting + self._state_view = state_view self._gantry_mover = gantry_mover self._model_utils = model_utils async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: """Dispense without moving the pipette.""" + state_update = StateUpdate() + current_location = self._state_view.pipettes.get_current_location() try: current_position = await self._gantry_mover.get_position(params.pipetteId) volume = await self._pipetting.dispense_in_place( @@ -78,6 +85,15 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -99,10 +115,21 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=volume, + ) return SuccessData( - public=DispenseInPlaceResult(volume=volume), private=None + public=DispenseInPlaceResult(volume=volume), + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index f4917a82195..81a34a5ad39 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -68,7 +68,7 @@ class DropTipResult(DestinationPositionResult): _ExecuteReturn = ( - SuccessData[DropTipResult, None] | DefinedErrorData[TipPhysicallyAttachedError] + SuccessData[DropTipResult] | DefinedErrorData[TipPhysicallyAttachedError] ) @@ -146,14 +146,21 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn: ) ], ) - return DefinedErrorData(public=error, state_update=state_update) + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) + return DefinedErrorData( + public=error, + state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, + ) else: state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) return SuccessData( public=DropTipResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py index 81b47e05c08..aa40384ac6a 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py @@ -44,8 +44,7 @@ class DropTipInPlaceResult(BaseModel): _ExecuteReturn = ( - SuccessData[DropTipInPlaceResult, None] - | DefinedErrorData[TipPhysicallyAttachedError] + SuccessData[DropTipInPlaceResult] | DefinedErrorData[TipPhysicallyAttachedError] ) @@ -72,6 +71,10 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: pipette_id=params.pipetteId, home_after=params.homeAfter ) except TipAttachedError as exception: + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) error = TipPhysicallyAttachedError( id=self._model_utils.generate_id(), createdAt=self._model_utils.get_timestamp(), @@ -83,14 +86,16 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: ) ], ) - return DefinedErrorData(public=error, state_update=state_update) + return DefinedErrorData( + public=error, + state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, + ) else: state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) - return SuccessData( - public=DropTipInPlaceResult(), private=None, state_update=state_update - ) + return SuccessData(public=DropTipInPlaceResult(), state_update=state_update) class DropTipInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/get_tip_presence.py b/api/src/opentrons/protocol_engine/commands/get_tip_presence.py index 6c4eea93a84..6bbe5fa2fe3 100644 --- a/api/src/opentrons/protocol_engine/commands/get_tip_presence.py +++ b/api/src/opentrons/protocol_engine/commands/get_tip_presence.py @@ -38,7 +38,7 @@ class GetTipPresenceResult(BaseModel): class GetTipPresenceImplementation( - AbstractCommandImpl[GetTipPresenceParams, SuccessData[GetTipPresenceResult, None]] + AbstractCommandImpl[GetTipPresenceParams, SuccessData[GetTipPresenceResult]] ): """GetTipPresence command implementation.""" @@ -51,7 +51,7 @@ def __init__( async def execute( self, params: GetTipPresenceParams - ) -> SuccessData[GetTipPresenceResult, None]: + ) -> SuccessData[GetTipPresenceResult]: """Verify if tip presence is as expected for the requested pipette.""" pipette_id = params.pipetteId @@ -59,7 +59,9 @@ async def execute( pipette_id=pipette_id, ) - return SuccessData(public=GetTipPresenceResult(status=result), private=None) + return SuccessData( + public=GetTipPresenceResult(status=result), + ) class GetTipPresence( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py index f9af6438958..2151fb05877 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py @@ -27,9 +27,7 @@ class CloseLabwareLatchResult(BaseModel): class CloseLabwareLatchImpl( - AbstractCommandImpl[ - CloseLabwareLatchParams, SuccessData[CloseLabwareLatchResult, None] - ] + AbstractCommandImpl[CloseLabwareLatchParams, SuccessData[CloseLabwareLatchResult]] ): """Execution implementation of a Heater-Shaker's close labware latch command.""" @@ -44,7 +42,7 @@ def __init__( async def execute( self, params: CloseLabwareLatchParams - ) -> SuccessData[CloseLabwareLatchResult, None]: + ) -> SuccessData[CloseLabwareLatchResult]: """Close a Heater-Shaker's labware latch.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -59,7 +57,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.close_labware_latch() - return SuccessData(public=CloseLabwareLatchResult(), private=None) + return SuccessData( + public=CloseLabwareLatchResult(), + ) class CloseLabwareLatch( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py index fb512b72319..3932f1d6490 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py @@ -27,9 +27,7 @@ class DeactivateHeaterResult(BaseModel): class DeactivateHeaterImpl( - AbstractCommandImpl[ - DeactivateHeaterParams, SuccessData[DeactivateHeaterResult, None] - ] + AbstractCommandImpl[DeactivateHeaterParams, SuccessData[DeactivateHeaterResult]] ): """Execution implementation of a Heater-Shaker's deactivate heater command.""" @@ -44,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateHeaterParams - ) -> SuccessData[DeactivateHeaterResult, None]: + ) -> SuccessData[DeactivateHeaterResult]: """Unset a Heater-Shaker's target temperature.""" hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( module_id=params.moduleId @@ -58,7 +56,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.deactivate_heater() - return SuccessData(public=DeactivateHeaterResult(), private=None) + return SuccessData( + public=DeactivateHeaterResult(), + ) class DeactivateHeater( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py index bc06b9767c4..b259745ea3d 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py @@ -26,9 +26,7 @@ class DeactivateShakerResult(BaseModel): class DeactivateShakerImpl( - AbstractCommandImpl[ - DeactivateShakerParams, SuccessData[DeactivateShakerResult, None] - ] + AbstractCommandImpl[DeactivateShakerParams, SuccessData[DeactivateShakerResult]] ): """Execution implementation of a Heater-Shaker's deactivate shaker command.""" @@ -43,7 +41,7 @@ def __init__( async def execute( self, params: DeactivateShakerParams - ) -> SuccessData[DeactivateShakerResult, None]: + ) -> SuccessData[DeactivateShakerResult]: """Deactivate shaker for a Heater-Shaker.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -60,7 +58,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.deactivate_shaker() - return SuccessData(public=DeactivateShakerResult(), private=None) + return SuccessData( + public=DeactivateShakerResult(), + ) class DeactivateShaker( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py index e39a2e200bf..9c3a9d8ae7d 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py @@ -34,9 +34,7 @@ class OpenLabwareLatchResult(BaseModel): class OpenLabwareLatchImpl( - AbstractCommandImpl[ - OpenLabwareLatchParams, SuccessData[OpenLabwareLatchResult, None] - ] + AbstractCommandImpl[OpenLabwareLatchParams, SuccessData[OpenLabwareLatchResult]] ): """Execution implementation of a Heater-Shaker's open latch labware command.""" @@ -53,7 +51,7 @@ def __init__( async def execute( self, params: OpenLabwareLatchParams - ) -> SuccessData[OpenLabwareLatchResult, None]: + ) -> SuccessData[OpenLabwareLatchResult]: """Open a Heater-Shaker's labware latch.""" state_update = update_types.StateUpdate() @@ -87,7 +85,6 @@ async def execute( return SuccessData( public=OpenLabwareLatchResult(pipetteRetracted=pipette_should_retract), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py index e3cf35142d6..8828195c658 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py @@ -36,7 +36,7 @@ class SetAndWaitForShakeSpeedResult(BaseModel): class SetAndWaitForShakeSpeedImpl( AbstractCommandImpl[ - SetAndWaitForShakeSpeedParams, SuccessData[SetAndWaitForShakeSpeedResult, None] + SetAndWaitForShakeSpeedParams, SuccessData[SetAndWaitForShakeSpeedResult] ] ): """Execution implementation of Heater-Shaker's set and wait shake speed command.""" @@ -55,7 +55,7 @@ def __init__( async def execute( self, params: SetAndWaitForShakeSpeedParams, - ) -> SuccessData[SetAndWaitForShakeSpeedResult, None]: + ) -> SuccessData[SetAndWaitForShakeSpeedResult]: """Set and wait for a Heater-Shaker's target shake speed.""" state_update = update_types.StateUpdate() @@ -94,7 +94,6 @@ async def execute( public=SetAndWaitForShakeSpeedResult( pipetteRetracted=pipette_should_retract ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py index 854004dabae..fa29390b910 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py @@ -29,7 +29,7 @@ class SetTargetTemperatureResult(BaseModel): class SetTargetTemperatureImpl( AbstractCommandImpl[ - SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult, None] + SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult] ] ): """Execution implementation of a Heater-Shaker's set temperature command.""" @@ -46,7 +46,7 @@ def __init__( async def execute( self, params: SetTargetTemperatureParams, - ) -> SuccessData[SetTargetTemperatureResult, None]: + ) -> SuccessData[SetTargetTemperatureResult]: """Set a Heater-Shaker's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -64,7 +64,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.start_set_temperature(validated_temp) - return SuccessData(public=SetTargetTemperatureResult(), private=None) + return SuccessData( + public=SetTargetTemperatureResult(), + ) class SetTargetTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py index fbd7ee24743..bb440a2674c 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py @@ -36,9 +36,7 @@ class WaitForTemperatureResult(BaseModel): class WaitForTemperatureImpl( - AbstractCommandImpl[ - WaitForTemperatureParams, SuccessData[WaitForTemperatureResult, None] - ] + AbstractCommandImpl[WaitForTemperatureParams, SuccessData[WaitForTemperatureResult]] ): """Execution implementation of a Heater-Shaker's wait for temperature command.""" @@ -53,7 +51,7 @@ def __init__( async def execute( self, params: WaitForTemperatureParams - ) -> SuccessData[WaitForTemperatureResult, None]: + ) -> SuccessData[WaitForTemperatureResult]: """Wait for a Heater-Shaker's target temperature to be reached.""" hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( module_id=params.moduleId @@ -72,7 +70,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.await_temperature(awaiting_temperature=target_temp) - return SuccessData(public=WaitForTemperatureResult(), private=None) + return SuccessData( + public=WaitForTemperatureResult(), + ) class WaitForTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/home.py b/api/src/opentrons/protocol_engine/commands/home.py index 93d988772dc..7b82f90e1fd 100644 --- a/api/src/opentrons/protocol_engine/commands/home.py +++ b/api/src/opentrons/protocol_engine/commands/home.py @@ -42,15 +42,13 @@ class HomeResult(BaseModel): """Result data from the execution of a Home command.""" -class HomeImplementation( - AbstractCommandImpl[HomeParams, SuccessData[HomeResult, None]] -): +class HomeImplementation(AbstractCommandImpl[HomeParams, SuccessData[HomeResult]]): """Home command implementation.""" def __init__(self, movement: MovementHandler, **kwargs: object) -> None: self._movement = movement - async def execute(self, params: HomeParams) -> SuccessData[HomeResult, None]: + async def execute(self, params: HomeParams) -> SuccessData[HomeResult]: """Home some or all motors to establish positional accuracy.""" state_update = update_types.StateUpdate() @@ -66,7 +64,7 @@ async def execute(self, params: HomeParams) -> SuccessData[HomeResult, None]: # preserve prior behavior, but we might only want to do this if we actually home. state_update.clear_all_pipette_locations() - return SuccessData(public=HomeResult(), private=None, state_update=state_update) + return SuccessData(public=HomeResult(), state_update=state_update) class Home(BaseCommand[HomeParams, HomeResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/liquid_probe.py b/api/src/opentrons/protocol_engine/commands/liquid_probe.py index 1a8597f9c03..f78cd5bb55c 100644 --- a/api/src/opentrons/protocol_engine/commands/liquid_probe.py +++ b/api/src/opentrons/protocol_engine/commands/liquid_probe.py @@ -11,6 +11,7 @@ MustHomeError, PipetteNotReadyToAspirateError, TipNotEmptyError, + IncompleteLabwareDefinitionError, ) from opentrons.types import MountType from opentrons_shared_data.errors.exceptions import ( @@ -85,10 +86,10 @@ class TryLiquidProbeResult(DestinationPositionResult): _LiquidProbeExecuteReturn = Union[ - SuccessData[LiquidProbeResult, None], + SuccessData[LiquidProbeResult], DefinedErrorData[LiquidNotFoundError], ] -_TryLiquidProbeExecuteReturn = SuccessData[TryLiquidProbeResult, None] +_TryLiquidProbeExecuteReturn = SuccessData[TryLiquidProbeResult] class _ExecuteCommonResult(NamedTuple): @@ -205,6 +206,13 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn: self._state_view, self._movement, self._pipetting, params ) if isinstance(z_pos_or_error, PipetteLiquidNotFoundError): + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=self._model_utils.get_timestamp(), + ) return DefinedErrorData( public=LiquidNotFoundError( id=self._model_utils.generate_id(), @@ -220,11 +228,27 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn: state_update=state_update, ) else: + try: + well_volume: float | update_types.ClearType = ( + self._state_view.geometry.get_well_volume_at_height( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos_or_error, + ) + ) + except IncompleteLabwareDefinitionError: + well_volume = update_types.CLEAR + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos_or_error, + volume=well_volume, + last_probed=self._model_utils.get_timestamp(), + ) return SuccessData( public=LiquidProbeResult( z_position=z_pos_or_error, position=deck_point ), - private=None, state_update=state_update, ) @@ -239,11 +263,13 @@ def __init__( state_view: StateView, movement: MovementHandler, pipetting: PipettingHandler, + model_utils: ModelUtils, **kwargs: object, ) -> None: self._state_view = state_view self._movement = movement self._pipetting = pipetting + self._model_utils = model_utils async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn: """Execute a `tryLiquidProbe` command. @@ -256,17 +282,31 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn: self._state_view, self._movement, self._pipetting, params ) - z_pos = ( - None - if isinstance(z_pos_or_error, PipetteLiquidNotFoundError) - else z_pos_or_error + if isinstance(z_pos_or_error, PipetteLiquidNotFoundError): + z_pos = None + well_volume: float | update_types.ClearType = update_types.CLEAR + else: + z_pos = z_pos_or_error + try: + well_volume = self._state_view.geometry.get_well_volume_at_height( + labware_id=params.labwareId, well_name=params.wellName, height=z_pos + ) + except IncompleteLabwareDefinitionError: + well_volume = update_types.CLEAR + + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos if z_pos is not None else update_types.CLEAR, + volume=well_volume, + last_probed=self._model_utils.get_timestamp(), ) + return SuccessData( public=TryLiquidProbeResult( z_position=z_pos, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 2de394c482c..05eccb95a7a 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -88,7 +88,7 @@ class LoadLabwareResult(BaseModel): class LoadLabwareImplementation( - AbstractCommandImpl[LoadLabwareParams, SuccessData[LoadLabwareResult, None]] + AbstractCommandImpl[LoadLabwareParams, SuccessData[LoadLabwareResult]] ): """Load labware command implementation.""" @@ -100,7 +100,7 @@ def __init__( async def execute( self, params: LoadLabwareParams - ) -> SuccessData[LoadLabwareResult, None]: + ) -> SuccessData[LoadLabwareResult]: """Load definition and calibration data necessary for a labware.""" # TODO (tz, 8-15-2023): extend column validation to column 1 when working # on https://opentrons.atlassian.net/browse/RSS-258 and completing @@ -167,7 +167,6 @@ async def execute( definition=loaded_labware.definition, offsetId=loaded_labware.offsetId, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/load_liquid.py b/api/src/opentrons/protocol_engine/commands/load_liquid.py index 856cf3ee127..5dd4737410e 100644 --- a/api/src/opentrons/protocol_engine/commands/load_liquid.py +++ b/api/src/opentrons/protocol_engine/commands/load_liquid.py @@ -4,11 +4,14 @@ from typing import Optional, Type, Dict, TYPE_CHECKING from typing_extensions import Literal +from opentrons.protocol_engine.state.update_types import StateUpdate + from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence if TYPE_CHECKING: from ..state.state import StateView + from ..resources import ModelUtils LoadLiquidCommandType = Literal["loadLiquid"] @@ -37,16 +40,17 @@ class LoadLiquidResult(BaseModel): class LoadLiquidImplementation( - AbstractCommandImpl[LoadLiquidParams, SuccessData[LoadLiquidResult, None]] + AbstractCommandImpl[LoadLiquidParams, SuccessData[LoadLiquidResult]] ): """Load liquid command implementation.""" - def __init__(self, state_view: StateView, **kwargs: object) -> None: + def __init__( + self, state_view: StateView, model_utils: ModelUtils, **kwargs: object + ) -> None: self._state_view = state_view + self._model_utils = model_utils - async def execute( - self, params: LoadLiquidParams - ) -> SuccessData[LoadLiquidResult, None]: + async def execute(self, params: LoadLiquidParams) -> SuccessData[LoadLiquidResult]: """Load data necessary for a liquid.""" self._state_view.liquid.validate_liquid_id(params.liquidId) @@ -54,7 +58,14 @@ async def execute( labware_id=params.labwareId, wells=params.volumeByWell ) - return SuccessData(public=LoadLiquidResult(), private=None) + state_update = StateUpdate() + state_update.set_liquid_loaded( + labware_id=params.labwareId, + volumes=params.volumeByWell, + last_loaded=self._model_utils.get_timestamp(), + ) + + return SuccessData(public=LoadLiquidResult(), state_update=state_update) class LoadLiquid(BaseCommand[LoadLiquidParams, LoadLiquidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index f8127658ea0..9560f4931c3 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -106,7 +106,7 @@ class LoadModuleResult(BaseModel): class LoadModuleImplementation( - AbstractCommandImpl[LoadModuleParams, SuccessData[LoadModuleResult, None]] + AbstractCommandImpl[LoadModuleParams, SuccessData[LoadModuleResult]] ): """The implementation of the load module command.""" @@ -116,9 +116,7 @@ def __init__( self._equipment = equipment self._state_view = state_view - async def execute( - self, params: LoadModuleParams - ) -> SuccessData[LoadModuleResult, None]: + async def execute(self, params: LoadModuleParams) -> SuccessData[LoadModuleResult]: """Check that the requested module is attached and assign its identifier.""" module_type = params.model.as_type() self._ensure_module_location(params.location.slotName, module_type) @@ -198,7 +196,6 @@ async def execute( model=loaded_module.definition.model, definition=loaded_module.definition, ), - private=None, ) def _ensure_module_location( diff --git a/api/src/opentrons/protocol_engine/commands/load_pipette.py b/api/src/opentrons/protocol_engine/commands/load_pipette.py index 5961272ae7c..7d09bf33028 100644 --- a/api/src/opentrons/protocol_engine/commands/load_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/load_pipette.py @@ -17,7 +17,6 @@ from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence -from .configuring_common import PipetteConfigUpdateResultMixin from ..errors import InvalidSpecificationForRobotTypeError, InvalidLoadPipetteSpecsError if TYPE_CHECKING: @@ -28,12 +27,6 @@ LoadPipetteCommandType = Literal["loadPipette"] -class LoadPipettePrivateResult(PipetteConfigUpdateResultMixin): - """The not-to-be-exposed results of a load pipette call.""" - - ... - - class LoadPipetteParams(BaseModel): """Payload needed to load a pipette on to a mount.""" @@ -73,9 +66,7 @@ class LoadPipetteResult(BaseModel): class LoadPipetteImplementation( - AbstractCommandImpl[ - LoadPipetteParams, SuccessData[LoadPipetteResult, LoadPipettePrivateResult] - ] + AbstractCommandImpl[LoadPipetteParams, SuccessData[LoadPipetteResult]] ): """Load pipette command implementation.""" @@ -87,7 +78,7 @@ def __init__( async def execute( self, params: LoadPipetteParams - ) -> SuccessData[LoadPipetteResult, LoadPipettePrivateResult]: + ) -> SuccessData[LoadPipetteResult]: """Check that requested pipette is attached and assign its identifier.""" pipette_generation = convert_to_pipette_name_type( params.pipetteName.value @@ -139,11 +130,6 @@ async def execute( return SuccessData( public=LoadPipetteResult(pipetteId=loaded_pipette.pipette_id), - private=LoadPipettePrivateResult( - pipette_id=loaded_pipette.pipette_id, - serial_number=loaded_pipette.serial_number, - config=loaded_pipette.static_config, - ), state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py b/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py index a1be2c8480f..c20b18e481d 100644 --- a/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py +++ b/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py @@ -38,7 +38,7 @@ class DisengageResult(BaseModel): class DisengageImplementation( - AbstractCommandImpl[DisengageParams, SuccessData[DisengageResult, None]] + AbstractCommandImpl[DisengageParams, SuccessData[DisengageResult]] ): """The implementation of a Magnetic Module disengage command.""" @@ -51,9 +51,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: DisengageParams - ) -> SuccessData[DisengageResult, None]: + async def execute(self, params: DisengageParams) -> SuccessData[DisengageResult]: """Execute a Magnetic Module disengage command. Raises: @@ -75,7 +73,9 @@ async def execute( if hardware_module is not None: # Not virtualizing modules. await hardware_module.deactivate() - return SuccessData(public=DisengageResult(), private=None) + return SuccessData( + public=DisengageResult(), + ) class Disengage(BaseCommand[DisengageParams, DisengageResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py b/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py index 3796f43a022..62f4e24eef4 100644 --- a/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py +++ b/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py @@ -54,7 +54,7 @@ class EngageResult(BaseModel): class EngageImplementation( - AbstractCommandImpl[EngageParams, SuccessData[EngageResult, None]] + AbstractCommandImpl[EngageParams, SuccessData[EngageResult]] ): """The implementation of a Magnetic Module engage command.""" @@ -67,7 +67,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute(self, params: EngageParams) -> SuccessData[EngageResult, None]: + async def execute(self, params: EngageParams) -> SuccessData[EngageResult]: """Execute a Magnetic Module engage command. Raises: @@ -95,7 +95,9 @@ async def execute(self, params: EngageParams) -> SuccessData[EngageResult, None] if hardware_module is not None: # Not virtualizing modules. await hardware_module.engage(height=hardware_height) - return SuccessData(public=EngageResult(), private=None) + return SuccessData( + public=EngageResult(), + ) class Engage(BaseCommand[EngageParams, EngageResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 7f52a8e83e4..0d2967e87d5 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -98,9 +98,7 @@ class GripperMovementError(ErrorOccurrence): errorType: Literal["gripperMovement"] = "gripperMovement" -_ExecuteReturn = ( - SuccessData[MoveLabwareResult, None] | DefinedErrorData[GripperMovementError] -) +_ExecuteReturn = SuccessData[MoveLabwareResult] | DefinedErrorData[GripperMovementError] class MoveLabwareImplementation(AbstractCommandImpl[MoveLabwareParams, _ExecuteReturn]): @@ -301,7 +299,6 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C return SuccessData( public=MoveLabwareResult(offsetId=new_offset_id), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_relative.py b/api/src/opentrons/protocol_engine/commands/move_relative.py index dc559648ea3..9133725727d 100644 --- a/api/src/opentrons/protocol_engine/commands/move_relative.py +++ b/api/src/opentrons/protocol_engine/commands/move_relative.py @@ -39,7 +39,7 @@ class MoveRelativeResult(DestinationPositionResult): class MoveRelativeImplementation( - AbstractCommandImpl[MoveRelativeParams, SuccessData[MoveRelativeResult, None]] + AbstractCommandImpl[MoveRelativeParams, SuccessData[MoveRelativeResult]] ): """Move relative command implementation.""" @@ -48,7 +48,7 @@ def __init__(self, movement: MovementHandler, **kwargs: object) -> None: async def execute( self, params: MoveRelativeParams - ) -> SuccessData[MoveRelativeResult, None]: + ) -> SuccessData[MoveRelativeResult]: """Move (jog) a given pipette a relative distance.""" state_update = update_types.StateUpdate() @@ -67,7 +67,6 @@ async def execute( return SuccessData( public=MoveRelativeResult(position=deck_point), - private=None, state_update=state_update, ) 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 cfdfbe77133..8247f54a266 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 @@ -76,7 +76,7 @@ class MoveToAddressableAreaResult(DestinationPositionResult): class MoveToAddressableAreaImplementation( AbstractCommandImpl[ - MoveToAddressableAreaParams, SuccessData[MoveToAddressableAreaResult, None] + MoveToAddressableAreaParams, SuccessData[MoveToAddressableAreaResult] ] ): """Move to addressable area command implementation.""" @@ -89,7 +89,7 @@ def __init__( async def execute( self, params: MoveToAddressableAreaParams - ) -> SuccessData[MoveToAddressableAreaResult, None]: + ) -> SuccessData[MoveToAddressableAreaResult]: """Move the requested pipette to the requested addressable area.""" state_update = update_types.StateUpdate() @@ -134,7 +134,6 @@ async def execute( return SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=x, y=y, z=z)), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py index 44244dcb25c..1c151f1e605 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py @@ -86,7 +86,7 @@ class MoveToAddressableAreaForDropTipResult(DestinationPositionResult): class MoveToAddressableAreaForDropTipImplementation( AbstractCommandImpl[ MoveToAddressableAreaForDropTipParams, - SuccessData[MoveToAddressableAreaForDropTipResult, None], + SuccessData[MoveToAddressableAreaForDropTipResult], ] ): """Move to addressable area for drop tip command implementation.""" @@ -99,7 +99,7 @@ def __init__( async def execute( self, params: MoveToAddressableAreaForDropTipParams - ) -> SuccessData[MoveToAddressableAreaForDropTipResult, None]: + ) -> SuccessData[MoveToAddressableAreaForDropTipResult]: """Move the requested pipette to the requested addressable area in preperation of a drop tip.""" state_update = update_types.StateUpdate() @@ -140,7 +140,6 @@ async def execute( public=MoveToAddressableAreaForDropTipResult( position=DeckPoint(x=x, y=y, z=z) ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py b/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py index fbc9f20e790..d7a0919d238 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py @@ -35,9 +35,7 @@ class MoveToCoordinatesResult(DestinationPositionResult): class MoveToCoordinatesImplementation( - AbstractCommandImpl[ - MoveToCoordinatesParams, SuccessData[MoveToCoordinatesResult, None] - ] + AbstractCommandImpl[MoveToCoordinatesParams, SuccessData[MoveToCoordinatesResult]] ): """Move to coordinates command implementation.""" @@ -50,7 +48,7 @@ def __init__( async def execute( self, params: MoveToCoordinatesParams - ) -> SuccessData[MoveToCoordinatesResult, None]: + ) -> SuccessData[MoveToCoordinatesResult]: """Move the requested pipette to the requested coordinates.""" state_update = update_types.StateUpdate() @@ -68,7 +66,6 @@ async def execute( return SuccessData( public=MoveToCoordinatesResult(position=DeckPoint(x=x, y=y, z=z)), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_well.py b/api/src/opentrons/protocol_engine/commands/move_to_well.py index 309f2e89513..49ab10111a4 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_well.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_well.py @@ -35,7 +35,7 @@ class MoveToWellResult(DestinationPositionResult): class MoveToWellImplementation( - AbstractCommandImpl[MoveToWellParams, SuccessData[MoveToWellResult, None]] + AbstractCommandImpl[MoveToWellParams, SuccessData[MoveToWellResult]] ): """Move to well command implementation.""" @@ -45,9 +45,7 @@ def __init__( self._state_view = state_view self._movement = movement - async def execute( - self, params: MoveToWellParams - ) -> SuccessData[MoveToWellResult, None]: + async def execute(self, params: MoveToWellParams) -> SuccessData[MoveToWellResult]: """Move the requested pipette to the requested well.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -83,7 +81,6 @@ async def execute( return SuccessData( public=MoveToWellResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 5ccdcfc6f3a..898929566fe 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -6,7 +6,7 @@ from typing_extensions import Literal -from ..errors import ErrorOccurrence, TipNotAttachedError +from ..errors import ErrorOccurrence, PickUpTipTipNotAttachedError from ..resources import ModelUtils from ..state import update_types from ..types import PickUpTipWellLocation, DeckPoint @@ -86,7 +86,7 @@ class TipPhysicallyMissingError(ErrorOccurrence): _ExecuteReturn = Union[ - SuccessData[PickUpTipResult, None], + SuccessData[PickUpTipResult], DefinedErrorData[TipPhysicallyMissingError], ] @@ -109,7 +109,7 @@ def __init__( async def execute( self, params: PickUpTipParams - ) -> Union[SuccessData[PickUpTipResult, None], _ExecuteReturn]: + ) -> Union[SuccessData[PickUpTipResult], _ExecuteReturn]: """Move to and pick up a tip using the requested pipette.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -140,7 +140,12 @@ async def execute( labware_id=labware_id, well_name=well_name, ) - except TipNotAttachedError as e: + except PickUpTipTipNotAttachedError as e: + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=pipette_id, + tip_geometry=e.tip_geometry, + ) state_update.mark_tips_as_used( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) @@ -157,6 +162,7 @@ async def execute( ], ), state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, ) else: state_update.update_pipette_tip_state( @@ -173,7 +179,6 @@ async def execute( tipDiameter=tip_geometry.diameter, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py index d63e42a7f90..01012be1d7f 100644 --- a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py @@ -40,7 +40,7 @@ class PrepareToAspirateResult(BaseModel): _ExecuteReturn = Union[ - SuccessData[PrepareToAspirateResult, None], + SuccessData[PrepareToAspirateResult], DefinedErrorData[OverpressureError], ] @@ -92,7 +92,9 @@ async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: ), ) else: - return SuccessData(public=PrepareToAspirateResult(), private=None) + return SuccessData( + public=PrepareToAspirateResult(), + ) class PrepareToAspirate( diff --git a/api/src/opentrons/protocol_engine/commands/reload_labware.py b/api/src/opentrons/protocol_engine/commands/reload_labware.py index 25f545736be..60230a1c6dd 100644 --- a/api/src/opentrons/protocol_engine/commands/reload_labware.py +++ b/api/src/opentrons/protocol_engine/commands/reload_labware.py @@ -47,7 +47,7 @@ class ReloadLabwareResult(BaseModel): class ReloadLabwareImplementation( - AbstractCommandImpl[ReloadLabwareParams, SuccessData[ReloadLabwareResult, None]] + AbstractCommandImpl[ReloadLabwareParams, SuccessData[ReloadLabwareResult]] ): """Reload labware command implementation.""" @@ -59,7 +59,7 @@ def __init__( async def execute( self, params: ReloadLabwareParams - ) -> SuccessData[ReloadLabwareResult, None]: + ) -> SuccessData[ReloadLabwareResult]: """Reload the definition and calibration data for a specific labware.""" reloaded_labware = await self._equipment.reload_labware( labware_id=params.labwareId, @@ -78,7 +78,6 @@ async def execute( labwareId=params.labwareId, offsetId=reloaded_labware.offsetId, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/retract_axis.py b/api/src/opentrons/protocol_engine/commands/retract_axis.py index 29e09acc064..49020eb517e 100644 --- a/api/src/opentrons/protocol_engine/commands/retract_axis.py +++ b/api/src/opentrons/protocol_engine/commands/retract_axis.py @@ -39,7 +39,7 @@ class RetractAxisResult(BaseModel): class RetractAxisImplementation( - AbstractCommandImpl[RetractAxisParams, SuccessData[RetractAxisResult, None]] + AbstractCommandImpl[RetractAxisParams, SuccessData[RetractAxisResult]] ): """Retract Axis command implementation.""" @@ -48,14 +48,12 @@ def __init__(self, movement: MovementHandler, **kwargs: object) -> None: async def execute( self, params: RetractAxisParams - ) -> SuccessData[RetractAxisResult, None]: + ) -> SuccessData[RetractAxisResult]: """Retract the specified axis.""" state_update = update_types.StateUpdate() await self._movement.retract_axis(axis=params.axis) state_update.clear_all_pipette_locations() - return SuccessData( - public=RetractAxisResult(), private=None, state_update=state_update - ) + return SuccessData(public=RetractAxisResult(), state_update=state_update) class RetractAxis(BaseCommand[RetractAxisParams, RetractAxisResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/save_position.py b/api/src/opentrons/protocol_engine/commands/save_position.py index 988e4b762a7..4bc208d1421 100644 --- a/api/src/opentrons/protocol_engine/commands/save_position.py +++ b/api/src/opentrons/protocol_engine/commands/save_position.py @@ -46,7 +46,7 @@ class SavePositionResult(BaseModel): class SavePositionImplementation( - AbstractCommandImpl[SavePositionParams, SuccessData[SavePositionResult, None]] + AbstractCommandImpl[SavePositionParams, SuccessData[SavePositionResult]] ): """Save position command implementation.""" @@ -61,7 +61,7 @@ def __init__( async def execute( self, params: SavePositionParams - ) -> SuccessData[SavePositionResult, None]: + ) -> SuccessData[SavePositionResult]: """Check the requested pipette's current position.""" position_id = self._model_utils.ensure_id(params.positionId) fail_on_not_homed = ( @@ -76,7 +76,6 @@ async def execute( positionId=position_id, position=DeckPoint(x=x, y=y, z=z), ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/set_rail_lights.py b/api/src/opentrons/protocol_engine/commands/set_rail_lights.py index 6235e0d9bb6..09254dbe966 100644 --- a/api/src/opentrons/protocol_engine/commands/set_rail_lights.py +++ b/api/src/opentrons/protocol_engine/commands/set_rail_lights.py @@ -29,7 +29,7 @@ class SetRailLightsResult(BaseModel): class SetRailLightsImplementation( - AbstractCommandImpl[SetRailLightsParams, SuccessData[SetRailLightsResult, None]] + AbstractCommandImpl[SetRailLightsParams, SuccessData[SetRailLightsResult]] ): """setRailLights command implementation.""" @@ -38,10 +38,12 @@ def __init__(self, rail_lights: RailLightsHandler, **kwargs: object) -> None: async def execute( self, params: SetRailLightsParams - ) -> SuccessData[SetRailLightsResult, None]: + ) -> SuccessData[SetRailLightsResult]: """Dispatch a set lights command setting the state of the rail lights.""" await self._rail_lights.set_rail_lights(params.on) - return SuccessData(public=SetRailLightsResult(), private=None) + return SuccessData( + public=SetRailLightsResult(), + ) class SetRailLights( diff --git a/api/src/opentrons/protocol_engine/commands/set_status_bar.py b/api/src/opentrons/protocol_engine/commands/set_status_bar.py index cb83aa56ce2..2e1483f6d93 100644 --- a/api/src/opentrons/protocol_engine/commands/set_status_bar.py +++ b/api/src/opentrons/protocol_engine/commands/set_status_bar.py @@ -49,7 +49,7 @@ class SetStatusBarResult(BaseModel): class SetStatusBarImplementation( - AbstractCommandImpl[SetStatusBarParams, SuccessData[SetStatusBarResult, None]] + AbstractCommandImpl[SetStatusBarParams, SuccessData[SetStatusBarResult]] ): """setStatusBar command implementation.""" @@ -58,12 +58,14 @@ def __init__(self, status_bar: StatusBarHandler, **kwargs: object) -> None: async def execute( self, params: SetStatusBarParams - ) -> SuccessData[SetStatusBarResult, None]: + ) -> SuccessData[SetStatusBarResult]: """Execute the setStatusBar command.""" if not self._status_bar.status_bar_should_not_be_changed(): state = _animation_to_status_bar_state(params.animation) await self._status_bar.set_status_bar(state) - return SuccessData(public=SetStatusBarResult(), private=None) + return SuccessData( + public=SetStatusBarResult(), + ) class SetStatusBar( diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py b/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py index 52e988b179d..e56c98e6e30 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py @@ -27,7 +27,7 @@ class DeactivateTemperatureResult(BaseModel): class DeactivateTemperatureImpl( AbstractCommandImpl[ - DeactivateTemperatureParams, SuccessData[DeactivateTemperatureResult, None] + DeactivateTemperatureParams, SuccessData[DeactivateTemperatureResult] ] ): """Execution implementation of a Temperature Module's deactivate command.""" @@ -43,7 +43,7 @@ def __init__( async def execute( self, params: DeactivateTemperatureParams - ) -> SuccessData[DeactivateTemperatureResult, None]: + ) -> SuccessData[DeactivateTemperatureResult]: """Deactivate a Temperature Module.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -57,7 +57,9 @@ async def execute( if temp_hardware_module is not None: await temp_hardware_module.deactivate() - return SuccessData(public=DeactivateTemperatureResult(), private=None) + return SuccessData( + public=DeactivateTemperatureResult(), + ) class DeactivateTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py b/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py index 7e76de7d561..6d65bf47bb0 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py @@ -34,7 +34,7 @@ class SetTargetTemperatureResult(BaseModel): class SetTargetTemperatureImpl( AbstractCommandImpl[ - SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult, None] + SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult] ] ): """Execution implementation of a Temperature Module's set temperature command.""" @@ -50,7 +50,7 @@ def __init__( async def execute( self, params: SetTargetTemperatureParams - ) -> SuccessData[SetTargetTemperatureResult, None]: + ) -> SuccessData[SetTargetTemperatureResult]: """Set a Temperature Module's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -69,7 +69,6 @@ async def execute( await temp_hardware_module.start_set_temperature(celsius=validated_temp) return SuccessData( public=SetTargetTemperatureResult(targetTemperature=validated_temp), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py b/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py index 7a96be35242..fa7784de141 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py @@ -35,9 +35,7 @@ class WaitForTemperatureResult(BaseModel): class WaitForTemperatureImpl( - AbstractCommandImpl[ - WaitForTemperatureParams, SuccessData[WaitForTemperatureResult, None] - ] + AbstractCommandImpl[WaitForTemperatureParams, SuccessData[WaitForTemperatureResult]] ): """Execution implementation of Temperature Module's wait for temperature command.""" @@ -52,7 +50,7 @@ def __init__( async def execute( self, params: WaitForTemperatureParams - ) -> SuccessData[WaitForTemperatureResult, None]: + ) -> SuccessData[WaitForTemperatureResult]: """Wait for a Temperature Module's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -74,7 +72,9 @@ async def execute( await temp_hardware_module.await_temperature( awaiting_temperature=target_temp ) - return SuccessData(public=WaitForTemperatureResult(), private=None) + return SuccessData( + public=WaitForTemperatureResult(), + ) class WaitForTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py index 12e1ab4b13f..578a5d6b7a7 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py @@ -28,9 +28,7 @@ class CloseLidResult(BaseModel): """Result data from closing a Thermocycler's lid.""" -class CloseLidImpl( - AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult, None]] -): +class CloseLidImpl(AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult]]): """Execution implementation of a Thermocycler's close lid command.""" def __init__( @@ -44,9 +42,7 @@ def __init__( self._equipment = equipment self._movement = movement - async def execute( - self, params: CloseLidParams - ) -> SuccessData[CloseLidResult, None]: + async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Close a Thermocycler's lid.""" state_update = update_types.StateUpdate() @@ -69,9 +65,7 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.close() - return SuccessData( - public=CloseLidResult(), private=None, state_update=state_update - ) + return SuccessData(public=CloseLidResult(), state_update=state_update) class CloseLid(BaseCommand[CloseLidParams, CloseLidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py index fd108dc9568..67199577d53 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py @@ -27,7 +27,7 @@ class DeactivateBlockResult(BaseModel): class DeactivateBlockImpl( - AbstractCommandImpl[DeactivateBlockParams, SuccessData[DeactivateBlockResult, None]] + AbstractCommandImpl[DeactivateBlockParams, SuccessData[DeactivateBlockResult]] ): """Execution implementation of a Thermocycler's deactivate block command.""" @@ -42,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateBlockParams - ) -> SuccessData[DeactivateBlockResult, None]: + ) -> SuccessData[DeactivateBlockResult]: """Unset a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -54,7 +54,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.deactivate_block() - return SuccessData(public=DeactivateBlockResult(), private=None) + return SuccessData( + public=DeactivateBlockResult(), + ) class DeactivateBlock( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py index ff0fabc1e88..9c3541efb12 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py @@ -27,7 +27,7 @@ class DeactivateLidResult(BaseModel): class DeactivateLidImpl( - AbstractCommandImpl[DeactivateLidParams, SuccessData[DeactivateLidResult, None]] + AbstractCommandImpl[DeactivateLidParams, SuccessData[DeactivateLidResult]] ): """Execution implementation of a Thermocycler's deactivate lid command.""" @@ -42,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateLidParams - ) -> SuccessData[DeactivateLidResult, None]: + ) -> SuccessData[DeactivateLidResult]: """Unset a Thermocycler's target lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -54,7 +54,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.deactivate_lid() - return SuccessData(public=DeactivateLidResult(), private=None) + return SuccessData( + public=DeactivateLidResult(), + ) class DeactivateLid( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py index e874a0b678c..3df32d1ec99 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py @@ -28,7 +28,7 @@ class OpenLidResult(BaseModel): """Result data from opening a Thermocycler's lid.""" -class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult, None]]): +class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult]]): """Execution implementation of a Thermocycler's open lid command.""" def __init__( @@ -42,7 +42,7 @@ def __init__( self._equipment = equipment self._movement = movement - async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, None]: + async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Open a Thermocycler's lid.""" state_update = update_types.StateUpdate() @@ -65,9 +65,7 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, Non if thermocycler_hardware is not None: await thermocycler_hardware.open() - return SuccessData( - public=OpenLidResult(), private=None, state_update=state_update - ) + return SuccessData(public=OpenLidResult(), state_update=state_update) class OpenLid(BaseCommand[OpenLidParams, OpenLidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py index 3cf8a67bf41..6f63aed8fe3 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py @@ -59,7 +59,6 @@ class RunExtendedProfileResult(BaseModel): def _transform_profile_step( step: ProfileStep, thermocycler_state: ThermocyclerModuleSubState ) -> ThermocyclerStep: - return ThermocyclerStep( temperature=thermocycler_state.validate_target_block_temperature(step.celsius), hold_time_seconds=step.holdSeconds, @@ -97,9 +96,7 @@ def _transform_profile_element( class RunExtendedProfileImpl( - AbstractCommandImpl[ - RunExtendedProfileParams, SuccessData[RunExtendedProfileResult, None] - ] + AbstractCommandImpl[RunExtendedProfileParams, SuccessData[RunExtendedProfileResult]] ): """Execution implementation of a Thermocycler's run profile command.""" @@ -114,7 +111,7 @@ def __init__( async def execute( self, params: RunExtendedProfileParams - ) -> SuccessData[RunExtendedProfileResult, None]: + ) -> SuccessData[RunExtendedProfileResult]: """Run a Thermocycler profile.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -142,7 +139,9 @@ async def execute( profile=profile, volume=target_volume ) - return SuccessData(public=RunExtendedProfileResult(), private=None) + return SuccessData( + public=RunExtendedProfileResult(), + ) class RunExtendedProfile( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py index c0b5189afcb..02aa7ad93e2 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py @@ -47,7 +47,7 @@ class RunProfileResult(BaseModel): class RunProfileImpl( - AbstractCommandImpl[RunProfileParams, SuccessData[RunProfileResult, None]] + AbstractCommandImpl[RunProfileParams, SuccessData[RunProfileResult]] ): """Execution implementation of a Thermocycler's run profile command.""" @@ -60,9 +60,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: RunProfileParams - ) -> SuccessData[RunProfileResult, None]: + async def execute(self, params: RunProfileParams) -> SuccessData[RunProfileResult]: """Run a Thermocycler profile.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -96,7 +94,9 @@ async def execute( steps=steps, repetitions=1, volume=target_volume ) - return SuccessData(public=RunProfileResult(), private=None) + return SuccessData( + public=RunProfileResult(), + ) class RunProfile(BaseCommand[RunProfileParams, RunProfileResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py index 587369b733b..b69bb15ea90 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py @@ -46,7 +46,7 @@ class SetTargetBlockTemperatureResult(BaseModel): class SetTargetBlockTemperatureImpl( AbstractCommandImpl[ SetTargetBlockTemperatureParams, - SuccessData[SetTargetBlockTemperatureResult, None], + SuccessData[SetTargetBlockTemperatureResult], ] ): """Execution implementation of a Thermocycler's set block temperature command.""" @@ -63,7 +63,7 @@ def __init__( async def execute( self, params: SetTargetBlockTemperatureParams, - ) -> SuccessData[SetTargetBlockTemperatureResult, None]: + ) -> SuccessData[SetTargetBlockTemperatureResult]: """Set a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -97,7 +97,6 @@ async def execute( public=SetTargetBlockTemperatureResult( targetBlockTemperature=target_temperature ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py index 5e7efa6bfd2..37217e047ae 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py @@ -34,7 +34,7 @@ class SetTargetLidTemperatureResult(BaseModel): class SetTargetLidTemperatureImpl( AbstractCommandImpl[ - SetTargetLidTemperatureParams, SuccessData[SetTargetLidTemperatureResult, None] + SetTargetLidTemperatureParams, SuccessData[SetTargetLidTemperatureResult] ] ): """Execution implementation of a Thermocycler's set lid temperature command.""" @@ -51,7 +51,7 @@ def __init__( async def execute( self, params: SetTargetLidTemperatureParams, - ) -> SuccessData[SetTargetLidTemperatureResult, None]: + ) -> SuccessData[SetTargetLidTemperatureResult]: """Set a Thermocycler's target lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -70,7 +70,6 @@ async def execute( public=SetTargetLidTemperatureResult( targetLidTemperature=target_temperature ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py index dabe351f352..8e8c9b1a4ec 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py @@ -28,7 +28,7 @@ class WaitForBlockTemperatureResult(BaseModel): class WaitForBlockTemperatureImpl( AbstractCommandImpl[ - WaitForBlockTemperatureParams, SuccessData[WaitForBlockTemperatureResult, None] + WaitForBlockTemperatureParams, SuccessData[WaitForBlockTemperatureResult] ] ): """Execution implementation of Thermocycler's wait for block temperature command.""" @@ -45,7 +45,7 @@ def __init__( async def execute( self, params: WaitForBlockTemperatureParams, - ) -> SuccessData[WaitForBlockTemperatureResult, None]: + ) -> SuccessData[WaitForBlockTemperatureResult]: """Wait for a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -61,7 +61,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.wait_for_block_target() - return SuccessData(public=WaitForBlockTemperatureResult(), private=None) + return SuccessData( + public=WaitForBlockTemperatureResult(), + ) class WaitForBlockTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py index d15eb4f3238..95e5fbc4f0a 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py @@ -28,7 +28,7 @@ class WaitForLidTemperatureResult(BaseModel): class WaitForLidTemperatureImpl( AbstractCommandImpl[ - WaitForLidTemperatureParams, SuccessData[WaitForLidTemperatureResult, None] + WaitForLidTemperatureParams, SuccessData[WaitForLidTemperatureResult] ] ): """Execution implementation of Thermocycler's wait for lid temperature command.""" @@ -45,7 +45,7 @@ def __init__( async def execute( self, params: WaitForLidTemperatureParams, - ) -> SuccessData[WaitForLidTemperatureResult, None]: + ) -> SuccessData[WaitForLidTemperatureResult]: """Wait for a Thermocycler's lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -61,7 +61,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.wait_for_lid_target() - return SuccessData(public=WaitForLidTemperatureResult(), private=None) + return SuccessData( + public=WaitForLidTemperatureResult(), + ) class WaitForLidTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/touch_tip.py b/api/src/opentrons/protocol_engine/commands/touch_tip.py index 744b1c14107..48c947abcbd 100644 --- a/api/src/opentrons/protocol_engine/commands/touch_tip.py +++ b/api/src/opentrons/protocol_engine/commands/touch_tip.py @@ -50,7 +50,7 @@ class TouchTipResult(DestinationPositionResult): class TouchTipImplementation( - AbstractCommandImpl[TouchTipParams, SuccessData[TouchTipResult, None]] + AbstractCommandImpl[TouchTipParams, SuccessData[TouchTipResult]] ): """Touch tip command implementation.""" @@ -65,9 +65,7 @@ def __init__( self._movement = movement self._gantry_mover = gantry_mover - async def execute( - self, params: TouchTipParams - ) -> SuccessData[TouchTipResult, None]: + async def execute(self, params: TouchTipParams) -> SuccessData[TouchTipResult]: """Touch tip to sides of a well using the requested pipette.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -119,7 +117,6 @@ async def execute( return SuccessData( public=TouchTipResult(position=final_deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 72698a3b0f2..eb138d89914 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -40,6 +40,15 @@ ) +from .unsafe_place_labware import ( + UnsafePlaceLabwareCommandType, + UnsafePlaceLabwareParams, + UnsafePlaceLabwareResult, + UnsafePlaceLabware, + UnsafePlaceLabwareCreate, +) + + __all__ = [ # Unsafe blow-out-in-place command models "UnsafeBlowOutInPlaceCommandType", @@ -71,4 +80,10 @@ "UnsafeUngripLabwareResult", "UnsafeUngripLabware", "UnsafeUngripLabwareCreate", + # Unsafe place labware + "UnsafePlaceLabwareCommandType", + "UnsafePlaceLabwareParams", + "UnsafePlaceLabwareResult", + "UnsafePlaceLabware", + "UnsafePlaceLabwareCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py index d9ef8e1d15d..4738b7c9b97 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py @@ -36,7 +36,7 @@ class UnsafeBlowOutInPlaceResult(BaseModel): class UnsafeBlowOutInPlaceImplementation( AbstractCommandImpl[ - UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult, None] + UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult] ] ): """UnsafeBlowOutInPlace command implementation.""" @@ -54,7 +54,7 @@ def __init__( async def execute( self, params: UnsafeBlowOutInPlaceParams - ) -> SuccessData[UnsafeBlowOutInPlaceResult, None]: + ) -> SuccessData[UnsafeBlowOutInPlaceResult]: """Blow-out without moving the pipette even when position is unknown.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) pipette_location = self._state_view.motion.get_pipette_location( @@ -67,7 +67,9 @@ async def execute( pipette_id=params.pipetteId, flow_rate=params.flowRate ) - return SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + return SuccessData( + public=UnsafeBlowOutInPlaceResult(), + ) class UnsafeBlowOutInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py index 33d4baebeea..ff749711cfb 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -42,7 +42,7 @@ class UnsafeDropTipInPlaceResult(BaseModel): class UnsafeDropTipInPlaceImplementation( AbstractCommandImpl[ - UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult, None] + UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult] ] ): """Unsafe drop tip in place command implementation.""" @@ -60,7 +60,7 @@ def __init__( async def execute( self, params: UnsafeDropTipInPlaceParams - ) -> SuccessData[UnsafeDropTipInPlaceResult, None]: + ) -> SuccessData[UnsafeDropTipInPlaceResult]: """Drop a tip using the requested pipette, even if the plunger position is not known.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) pipette_location = self._state_view.motion.get_pipette_location( @@ -79,7 +79,7 @@ async def execute( ) return SuccessData( - public=UnsafeDropTipInPlaceResult(), private=None, state_update=state_update + public=UnsafeDropTipInPlaceResult(), state_update=state_update ) diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py index 500347d84b0..02bc22b0396 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py @@ -32,7 +32,7 @@ class UnsafeEngageAxesResult(BaseModel): class UnsafeEngageAxesImplementation( AbstractCommandImpl[ UnsafeEngageAxesParams, - SuccessData[UnsafeEngageAxesResult, None], + SuccessData[UnsafeEngageAxesResult], ] ): """Enable axes command implementation.""" @@ -48,7 +48,7 @@ def __init__( async def execute( self, params: UnsafeEngageAxesParams - ) -> SuccessData[UnsafeEngageAxesResult, None]: + ) -> SuccessData[UnsafeEngageAxesResult]: """Enable exes.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.engage_axes( @@ -57,7 +57,9 @@ async def execute( for axis in params.axes ] ) - return SuccessData(public=UnsafeEngageAxesResult(), private=None) + return SuccessData( + public=UnsafeEngageAxesResult(), + ) class UnsafeEngageAxes( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py new file mode 100644 index 00000000000..547b8416637 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -0,0 +1,194 @@ +"""Place labware payload, result, and implementaiton.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, Type, cast +from typing_extensions import Literal + +from opentrons.hardware_control.types import Axis, OT3Mount +from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints +from opentrons.protocol_engine.errors.exceptions import ( + CannotPerformGripperAction, + GripperNotAttachedError, +) +from opentrons.types import Point + +from ...types import DeckSlotLocation, ModuleModel, OnDeckLabwareLocation +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware +from ...state.update_types import StateUpdate + +from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI + +if TYPE_CHECKING: + from ...state.state import StateView + from ...execution.equipment import EquipmentHandler + + +UnsafePlaceLabwareCommandType = Literal["unsafe/placeLabware"] + + +class UnsafePlaceLabwareParams(BaseModel): + """Payload required for an UnsafePlaceLabware command.""" + + labwareId: str = Field(..., description="The id of the labware to place.") + location: OnDeckLabwareLocation = Field( + ..., description="Where to place the labware." + ) + + +class UnsafePlaceLabwareResult(BaseModel): + """Result data from the execution of an UnsafePlaceLabware command.""" + + +class UnsafePlaceLabwareImplementation( + AbstractCommandImpl[ + UnsafePlaceLabwareParams, + SuccessData[UnsafePlaceLabwareResult], + ] +): + """The UnsafePlaceLabware command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + state_view: StateView, + equipment: EquipmentHandler, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + self._state_view = state_view + self._equipment = equipment + + async def execute( + self, params: UnsafePlaceLabwareParams + ) -> SuccessData[UnsafePlaceLabwareResult]: + """Place Labware. + + This command is used only when the gripper is in the middle of moving + labware but is interrupted before completing the move. (i.e., the e-stop + is pressed, get into error recovery, etc). + + Unlike the `moveLabware` command, where you pick a source and destination + location, this command takes the labwareId to be moved and location to + move it to. + + """ + ot3api = ensure_ot3_hardware(self._hardware_api) + if not ot3api.has_gripper(): + raise GripperNotAttachedError("No gripper found to perform labware place.") + + if ot3api.gripper_jaw_can_home(): + raise CannotPerformGripperAction( + "Cannot place labware when gripper is not gripping." + ) + + # Allow propagation of LabwareNotLoadedError. + labware_id = params.labwareId + definition_uri = self._state_view.labware.get(labware_id).definitionUri + final_offsets = self._state_view.labware.get_labware_gripper_offsets( + labware_id, None + ) + drop_offset = cast(Point, final_offsets.dropOffset) if final_offsets else None + + if isinstance(params.location, DeckSlotLocation): + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) + + location = self._state_view.geometry.ensure_valid_gripper_location( + params.location, + ) + + # This is an absorbance reader, move the lid to its dock (staging area). + if isinstance(location, DeckSlotLocation): + module = self._state_view.modules.get_by_slot(location.slotName) + if module and module.model == ModuleModel.ABSORBANCE_READER_V1: + location = self._state_view.modules.absorbance_reader_dock_location( + module.id + ) + + new_offset_id = self._equipment.find_applicable_labware_offset_id( + labware_definition_uri=definition_uri, + labware_location=location, + ) + + # NOTE: When the estop is pressed, the gantry loses position, + # so the robot needs to home x, y to sync. + await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G, Axis.X, Axis.Y]) + state_update = StateUpdate() + + # Place the labware down + await self._start_movement(ot3api, labware_id, location, drop_offset) + + state_update.set_labware_location( + labware_id=labware_id, + new_location=location, + new_offset_id=new_offset_id, + ) + return SuccessData(public=UnsafePlaceLabwareResult(), state_update=state_update) + + async def _start_movement( + self, + ot3api: OT3HardwareControlAPI, + labware_id: str, + location: OnDeckLabwareLocation, + drop_offset: Optional[Point], + ) -> None: + gripper_homed_position = await ot3api.gantry_position( + mount=OT3Mount.GRIPPER, + refresh=True, + ) + + to_labware_center = self._state_view.geometry.get_labware_grip_point( + labware_id=labware_id, location=location + ) + + movement_waypoints = get_gripper_labware_placement_waypoints( + to_labware_center=to_labware_center, + gripper_home_z=gripper_homed_position.z, + drop_offset=drop_offset, + ) + + # start movement + for waypoint_data in movement_waypoints: + if waypoint_data.jaw_open: + if waypoint_data.dropping: + # This `disengage_axes` step is important in order to engage + # the electronic brake on the Z axis of the gripper. The brake + # has a stronger holding force on the axis than the hold current, + # and prevents the axis from spuriously dropping when e.g. the notch + # on the side of a falling tiprack catches the jaw. + await ot3api.disengage_axes([Axis.Z_G]) + await ot3api.ungrip() + if waypoint_data.dropping: + # We lost the position estimation after disengaging the axis, so + # it is necessary to home it next + await ot3api.home_z(OT3Mount.GRIPPER) + await ot3api.move_to( + mount=OT3Mount.GRIPPER, abs_position=waypoint_data.position + ) + + +class UnsafePlaceLabware( + BaseCommand[UnsafePlaceLabwareParams, UnsafePlaceLabwareResult, ErrorOccurrence] +): + """UnsafePlaceLabware command model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + result: Optional[UnsafePlaceLabwareResult] + + _ImplementationCls: Type[ + UnsafePlaceLabwareImplementation + ] = UnsafePlaceLabwareImplementation + + +class UnsafePlaceLabwareCreate(BaseCommandCreate[UnsafePlaceLabwareParams]): + """UnsafePlaceLabware command request model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + + _CommandCls: Type[UnsafePlaceLabware] = UnsafePlaceLabware diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py index e64beaa7ea7..9674513d749 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py @@ -27,7 +27,7 @@ class UnsafeUngripLabwareResult(BaseModel): class UnsafeUngripLabwareImplementation( AbstractCommandImpl[ UnsafeUngripLabwareParams, - SuccessData[UnsafeUngripLabwareResult, None], + SuccessData[UnsafeUngripLabwareResult], ] ): """Ungrip labware command implementation.""" @@ -41,13 +41,15 @@ def __init__( async def execute( self, params: UnsafeUngripLabwareParams - ) -> SuccessData[UnsafeUngripLabwareResult, None]: + ) -> SuccessData[UnsafeUngripLabwareResult]: """Ungrip Labware.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) if not ot3_hardware_api.has_gripper(): raise GripperNotAttachedError("No gripper found to perform ungrip.") await ot3_hardware_api.ungrip() - return SuccessData(public=UnsafeUngripLabwareResult(), private=None) + return SuccessData( + public=UnsafeUngripLabwareResult(), + ) class UnsafeUngripLabware( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py index 96be2eb8551..cf5454db332 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py @@ -34,7 +34,7 @@ class UpdatePositionEstimatorsResult(BaseModel): class UpdatePositionEstimatorsImplementation( AbstractCommandImpl[ UpdatePositionEstimatorsParams, - SuccessData[UpdatePositionEstimatorsResult, None], + SuccessData[UpdatePositionEstimatorsResult], ] ): """Update position estimators command implementation.""" @@ -50,7 +50,7 @@ def __init__( async def execute( self, params: UpdatePositionEstimatorsParams - ) -> SuccessData[UpdatePositionEstimatorsResult, None]: + ) -> SuccessData[UpdatePositionEstimatorsResult]: """Update axis position estimators from their encoders.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.update_axis_position_estimations( @@ -59,7 +59,9 @@ async def execute( for axis in params.axes ] ) - return SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + return SuccessData( + public=UpdatePositionEstimatorsResult(), + ) class UpdatePositionEstimators( diff --git a/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py b/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py index 9816e03cf33..e0412022e85 100644 --- a/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py +++ b/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py @@ -36,9 +36,7 @@ class VerifyTipPresenceResult(BaseModel): class VerifyTipPresenceImplementation( - AbstractCommandImpl[ - VerifyTipPresenceParams, SuccessData[VerifyTipPresenceResult, None] - ] + AbstractCommandImpl[VerifyTipPresenceParams, SuccessData[VerifyTipPresenceResult]] ): """VerifyTipPresence command implementation.""" @@ -51,7 +49,7 @@ def __init__( async def execute( self, params: VerifyTipPresenceParams - ) -> SuccessData[VerifyTipPresenceResult, None]: + ) -> SuccessData[VerifyTipPresenceResult]: """Verify if tip presence is as expected for the requested pipette.""" pipette_id = params.pipetteId expected_state = params.expectedState @@ -67,7 +65,9 @@ async def execute( follow_singular_sensor=follow_singular_sensor, ) - return SuccessData(public=VerifyTipPresenceResult(), private=None) + return SuccessData( + public=VerifyTipPresenceResult(), + ) class VerifyTipPresence( diff --git a/api/src/opentrons/protocol_engine/commands/wait_for_duration.py b/api/src/opentrons/protocol_engine/commands/wait_for_duration.py index df1eae28aa4..04f8693386e 100644 --- a/api/src/opentrons/protocol_engine/commands/wait_for_duration.py +++ b/api/src/opentrons/protocol_engine/commands/wait_for_duration.py @@ -29,7 +29,7 @@ class WaitForDurationResult(BaseModel): class WaitForDurationImplementation( - AbstractCommandImpl[WaitForDurationParams, SuccessData[WaitForDurationResult, None]] + AbstractCommandImpl[WaitForDurationParams, SuccessData[WaitForDurationResult]] ): """Wait for duration command implementation.""" @@ -38,10 +38,12 @@ def __init__(self, run_control: RunControlHandler, **kwargs: object) -> None: async def execute( self, params: WaitForDurationParams - ) -> SuccessData[WaitForDurationResult, None]: + ) -> SuccessData[WaitForDurationResult]: """Wait for a duration of time.""" await self._run_control.wait_for_duration(params.seconds) - return SuccessData(public=WaitForDurationResult(), private=None) + return SuccessData( + public=WaitForDurationResult(), + ) class WaitForDuration( diff --git a/api/src/opentrons/protocol_engine/commands/wait_for_resume.py b/api/src/opentrons/protocol_engine/commands/wait_for_resume.py index c6036f852e2..f5066d52521 100644 --- a/api/src/opentrons/protocol_engine/commands/wait_for_resume.py +++ b/api/src/opentrons/protocol_engine/commands/wait_for_resume.py @@ -30,7 +30,7 @@ class WaitForResumeResult(BaseModel): class WaitForResumeImplementation( - AbstractCommandImpl[WaitForResumeParams, SuccessData[WaitForResumeResult, None]] + AbstractCommandImpl[WaitForResumeParams, SuccessData[WaitForResumeResult]] ): """Wait for resume command implementation.""" @@ -39,10 +39,12 @@ def __init__(self, run_control: RunControlHandler, **kwargs: object) -> None: async def execute( self, params: WaitForResumeParams - ) -> SuccessData[WaitForResumeResult, None]: + ) -> SuccessData[WaitForResumeResult]: """Dispatch a PauseAction to the store to pause the protocol.""" await self._run_control.wait_for_resume() - return SuccessData(public=WaitForResumeResult(), private=None) + return SuccessData( + public=WaitForResumeResult(), + ) class WaitForResume( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index dc66591eff2..372972c1f50 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -5,12 +5,20 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import DoorState -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy +from opentrons.protocol_engine.execution.error_recovery_hardware_state_synchronizer import ( + ErrorRecoveryHardwareStateSynchronizer, +) from opentrons.util.async_helpers import async_context_manager_in_thread + from opentrons_shared_data.robot import load as load_robot +from .actions.action_dispatcher import ActionDispatcher +from .error_recovery_policy import ErrorRecoveryPolicy +from .execution.door_watcher import DoorWatcher +from .execution.hardware_stopper import HardwareStopper +from .plugins import PluginStarter from .protocol_engine import ProtocolEngine -from .resources import DeckDataProvider, ModuleDataProvider, FileProvider +from .resources import DeckDataProvider, ModuleDataProvider, FileProvider, ModelUtils from .state.config import Config from .state.state import StateStore from .types import PostRunHardwareState, DeckConfigurationType @@ -61,10 +69,27 @@ async def create_protocol_engine( deck_configuration=deck_configuration, notify_publishers=notify_publishers, ) + hardware_state_synchronizer = ErrorRecoveryHardwareStateSynchronizer( + hardware_api, state_store + ) + action_dispatcher = ActionDispatcher(state_store) + action_dispatcher.add_handler(hardware_state_synchronizer) + plugin_starter = PluginStarter(state_store, action_dispatcher) + model_utils = ModelUtils() + hardware_stopper = HardwareStopper(hardware_api, state_store) + door_watcher = DoorWatcher(state_store, hardware_api, action_dispatcher) + module_data_provider = ModuleDataProvider() + file_provider = file_provider or FileProvider() return ProtocolEngine( - state_store=state_store, hardware_api=hardware_api, + state_store=state_store, + action_dispatcher=action_dispatcher, + plugin_starter=plugin_starter, + model_utils=model_utils, + hardware_stopper=hardware_stopper, + door_watcher=door_watcher, + module_data_provider=module_data_provider, file_provider=file_provider, ) diff --git a/api/src/opentrons/protocol_engine/error_recovery_policy.py b/api/src/opentrons/protocol_engine/error_recovery_policy.py index d959651393e..fcc8a2ffef5 100644 --- a/api/src/opentrons/protocol_engine/error_recovery_policy.py +++ b/api/src/opentrons/protocol_engine/error_recovery_policy.py @@ -26,10 +26,20 @@ class ErrorRecoveryType(enum.Enum): """ WAIT_FOR_RECOVERY = enum.auto() - """Stop and wait for the error to be recovered from manually.""" + """Enter interactive error recovery mode.""" - IGNORE_AND_CONTINUE = enum.auto() - """Continue with the run, as if the command never failed.""" + CONTINUE_WITH_ERROR = enum.auto() + """Continue without interruption, carrying on from whatever error state the failed + command left the engine in. + + This is like `ProtocolEngine.resume_from_recovery(reconcile_false_positive=False)`. + """ + + ASSUME_FALSE_POSITIVE_AND_CONTINUE = enum.auto() + """Continue without interruption, acting as if the underlying error was a false positive. + + This is like `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + """ class ErrorRecoveryPolicy(Protocol): diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 9bbe3aae9b8..b25dfdb2d0e 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -8,6 +8,7 @@ InvalidSpecificationForRobotTypeError, InvalidLoadPipetteSpecsError, TipNotAttachedError, + PickUpTipTipNotAttachedError, TipAttachedError, CommandDoesNotExistError, LabwareNotLoadedError, @@ -89,6 +90,7 @@ "InvalidSpecificationForRobotTypeError", "InvalidLoadPipetteSpecsError", "TipNotAttachedError", + "PickUpTipTipNotAttachedError", "TipAttachedError", "CommandDoesNotExistError", "LabwareNotLoadedError", diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 5656942b338..12f45f4936d 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1,11 +1,17 @@ """Protocol engine exceptions.""" +from __future__ import annotations + from logging import getLogger -from typing import Any, Dict, Optional, Union, Iterator, Sequence +from typing import Any, Dict, Final, Optional, Union, Iterator, Sequence, TYPE_CHECKING from opentrons_shared_data.errors import ErrorCodes from opentrons_shared_data.errors.exceptions import EnumeratedError, PythonException +if TYPE_CHECKING: + from opentrons.protocol_engine.types import TipGeometry + + log = getLogger(__name__) @@ -132,6 +138,21 @@ def __init__( super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, details, wrapping) +class PickUpTipTipNotAttachedError(TipNotAttachedError): + """Raised from TipHandler.pick_up_tip(). + + This is like TipNotAttachedError except that it carries some extra information + about the attempted operation. + """ + + tip_geometry: Final[TipGeometry] + """The tip geometry that would have been on the pipette, had the operation succeeded.""" + + def __init__(self, tip_geometry: TipGeometry) -> None: + super().__init__() + self.tip_geometry = tip_geometry + + class TipAttachedError(ProtocolEngineError): """Raised when a tip shouldn't be attached, but is.""" diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index 1d30b8756d2..b6c686e0b11 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -12,6 +12,7 @@ ) from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.notes import make_error_recovery_debug_note from ..state.state import StateStore from ..resources import ModelUtils, FileProvider @@ -161,6 +162,12 @@ async def execute(self, command_id: str) -> None: elif not isinstance(error, EnumeratedError): error = PythonException(error) + error_recovery_type = error_recovery_policy( + self._state_store.config, + running_command, + None, + ) + note_tracker(make_error_recovery_debug_note(error_recovery_type)) self._action_dispatcher.dispatch( FailCommandAction( error=error, @@ -169,11 +176,7 @@ async def execute(self, command_id: str) -> None: error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), notes=note_tracker.get_notes(), - type=error_recovery_policy( - self._state_store.config, - running_command, - None, - ), + type=error_recovery_type, ) ) @@ -189,12 +192,17 @@ async def execute(self, command_id: str) -> None: self._action_dispatcher.dispatch( SucceedCommandAction( command=succeeded_command, - private_result=result.private, state_update=result.state_update, ), ) else: # The command encountered a defined error. + error_recovery_type = error_recovery_policy( + self._state_store.config, + running_command, + result, + ) + note_tracker(make_error_recovery_debug_note(error_recovery_type)) self._action_dispatcher.dispatch( FailCommandAction( error=result, @@ -203,10 +211,6 @@ async def execute(self, command_id: str) -> None: error_id=result.public.id, failed_at=result.public.createdAt, notes=note_tracker.get_notes(), - type=error_recovery_policy( - self._state_store.config, - running_command, - result, - ), + type=error_recovery_type, ) ) diff --git a/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py b/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py new file mode 100644 index 00000000000..67d75cfb181 --- /dev/null +++ b/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py @@ -0,0 +1,101 @@ +# noqa: D100 + + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.protocol_engine.actions.action_handler import ActionHandler +from opentrons.protocol_engine.actions.actions import ( + Action, + FailCommandAction, + ResumeFromRecoveryAction, +) +from opentrons.protocol_engine.commands.command import DefinedErrorData +from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType +from opentrons.protocol_engine.execution.tip_handler import HardwareTipHandler +from opentrons.protocol_engine.state import update_types +from opentrons.protocol_engine.state.state import StateView + + +class ErrorRecoveryHardwareStateSynchronizer(ActionHandler): + """A hack to keep the hardware API's state correct through certain error recovery flows. + + BACKGROUND: + + Certain parts of robot state are duplicated between `opentrons.protocol_engine` and + `opentrons.hardware_control`. Stuff like "is there a tip attached." + + Normally, Protocol Engine command implementations (`opentrons.protocol_engine.commands`) + mutate hardware API state when they execute; and then when they finish executing, + the Protocol Engine state stores (`opentrons.protocol_engine.state`) update Protocol + Engine state accordingly. So both halves are accounted for. This generally works fine. + + However, we need to go out of our way to support + `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + It wants to apply a second set of state updates to "fix things up" with the + new knowledge that some error was a false positive. The Protocol Engine half of that + is easy for us to apply the normal way, through the state stores; but the + hardware API half of that cannot be applied the normal way, from the command + implementation, because the command in question is no longer running. + + THE HACK: + + This listens for the same error recovery state updates that the state stores do, + figures out what hardware API state mutations ought to go along with them, + and then does those mutations. + + The problem is that hardware API state is now mutated from two different places + (sometimes the command implementations, and sometimes here), which are bound + to grow accidental differences. + + TO FIX: + + Make Protocol Engine's use of the hardware API less stateful. e.g. supply + tip geometry every time we call a hardware API movement method, instead of + just once when we pick up a tip. Use Protocol Engine state as the single source + of truth. + """ + + def __init__(self, hardware_api: HardwareControlAPI, state_view: StateView) -> None: + self._hardware_api = hardware_api + self._state_view = state_view + + def handle_action(self, action: Action) -> None: + """Modify hardware API state in reaction to a Protocol Engine action.""" + state_update = _get_state_update(action) + if state_update: + self._synchronize(state_update) + + def _synchronize(self, state_update: update_types.StateUpdate) -> None: + tip_handler = HardwareTipHandler(self._state_view, self._hardware_api) + + if state_update.pipette_tip_state != update_types.NO_CHANGE: + pipette_id = state_update.pipette_tip_state.pipette_id + tip_geometry = state_update.pipette_tip_state.tip_geometry + if tip_geometry is None: + tip_handler.remove_tip(pipette_id) + else: + tip_handler.cache_tip(pipette_id=pipette_id, tip=tip_geometry) + + +def _get_state_update(action: Action) -> update_types.StateUpdate | None: + """Get the mutations that we need to do on the hardware API to stay in sync with an engine action. + + The mutations are returned in Protocol Engine terms, as a StateUpdate. + They then need to be converted to hardware API terms. + """ + match action: + case ResumeFromRecoveryAction(state_update=state_update): + return state_update + + case FailCommandAction( + error=DefinedErrorData( + state_update_if_false_positive=state_update_if_false_positive + ) + ): + return ( + state_update_if_false_positive + if action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE + else None + ) + + case _: + return None diff --git a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py index 24055f6b03b..81d4f10d94d 100644 --- a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py +++ b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py @@ -78,9 +78,7 @@ async def _drop_tip(self) -> None: try: if self._state_store.labware.get_fixed_trash_id() == FIXED_TRASH_ID: # OT-2 and Flex 2.15 protocols will default to the Fixed Trash Labware - await self._tip_handler.cache_tip( - pipette_id=pipette_id, tip=tip - ) + self._tip_handler.cache_tip(pipette_id=pipette_id, tip=tip) await self._movement_handler.move_to_well( pipette_id=pipette_id, labware_id=FIXED_TRASH_ID, @@ -92,9 +90,7 @@ async def _drop_tip(self) -> None: ) elif self._state_store.config.robot_type == "OT-2 Standard": # API 2.16 and above OT2 protocols use addressable areas - await self._tip_handler.cache_tip( - pipette_id=pipette_id, tip=tip - ) + self._tip_handler.cache_tip(pipette_id=pipette_id, tip=tip) await self._movement_handler.move_to_addressable_area( pipette_id=pipette_id, addressable_area_name="fixedTrash", diff --git a/api/src/opentrons/protocol_engine/execution/queue_worker.py b/api/src/opentrons/protocol_engine/execution/queue_worker.py index 67f8f17b42c..015adf085c9 100644 --- a/api/src/opentrons/protocol_engine/execution/queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/queue_worker.py @@ -69,7 +69,11 @@ async def join(self) -> None: async def _run_commands(self) -> None: async for command_id in self._command_generator(): - await self._command_executor.execute(command_id=command_id) + try: + await self._command_executor.execute(command_id=command_id) + except BaseException: + log.exception("Unhandled failure in command executor") + raise # Yield to the event loop in case we're executing a long sequence of commands # that never yields internally. For example, a long sequence of comment commands. await asyncio.sleep(0) diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index a963dd9abac..dde67ece007 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -4,6 +4,9 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import FailedTipStateCheck, InstrumentProbeType +from opentrons.protocol_engine.errors.exceptions import PickUpTipTipNotAttachedError +from opentrons.types import Mount + from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -70,7 +73,7 @@ async def pick_up_tip( Tip geometry of the picked up tip. Raises: - TipNotAttachedError + PickUpTipTipNotAttachedError """ ... @@ -83,9 +86,12 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: TipAttachedError """ - async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Tell the Hardware API that a tip is attached.""" + def remove_tip(self, pipette_id: str) -> None: + """Tell the hardware API that no tip is attached.""" + async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: """Get tip presence status on the pipette.""" @@ -198,6 +204,11 @@ def __init__( self._labware_data_provider = labware_data_provider or LabwareDataProvider() self._state_view = state_view + # WARNING: ErrorRecoveryHardwareStateSynchronizer can currently construct several + # instances of this class per run, in addition to the main instance used + # for command execution. We're therefore depending on this class being + # stateless, so consider that before adding additional attributes here. + async def available_for_nozzle_layout( self, pipette_id: str, @@ -223,7 +234,7 @@ async def pick_up_tip( well_name: str, ) -> TipGeometry: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) nominal_tip_geometry = self._state_view.geometry.get_nominal_tip_geometry( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name @@ -234,6 +245,7 @@ async def pick_up_tip( labware_definition=self._state_view.labware.get_definition(labware_id), nominal_fallback=nominal_tip_geometry.length, ) + tip_geometry = TipGeometry( length=actual_tip_length, diameter=nominal_tip_geometry.diameter, @@ -243,10 +255,12 @@ async def pick_up_tip( await self._hardware_api.tip_pickup_moves( mount=hw_mount, presses=None, increment=None ) - # Allow TipNotAttachedError to propagate. - await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) + try: + await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) + except TipNotAttachedError as e: + raise PickUpTipTipNotAttachedError(tip_geometry=tip_geometry) from e - await self.cache_tip(pipette_id, tip_geometry) + self.cache_tip(pipette_id, tip_geometry) await self._hardware_api.prepare_for_aspirate(hw_mount) @@ -254,7 +268,7 @@ async def pick_up_tip( async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) # Let the hardware controller handle defaulting home_after since its behavior # differs between machines @@ -268,12 +282,11 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: # Allow TipNotAttachedError to propagate. await self.verify_tip_presence(pipette_id, TipPresenceStatus.ABSENT) - self._hardware_api.remove_tip(hw_mount) - self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) + self.remove_tip(pipette_id) - async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) self._hardware_api.cache_tip(mount=hw_mount, tip_length=tip.length) @@ -287,12 +300,18 @@ async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: tip_volume=tip.volume, ) + def remove_tip(self, pipette_id: str) -> None: + """See documentation on abstract base class.""" + hw_mount = self._get_hw_mount(pipette_id) + self._hardware_api.remove_tip(hw_mount) + self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) + async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: """See documentation on abstract base class.""" try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) status = await ot3api.get_tip_presence_status(hw_mount) return TipPresenceStatus.from_hw_state(status) @@ -333,7 +352,7 @@ async def verify_tip_presence( return try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) await ot3api.verify_tip_presence( hw_mount, expected.to_hw_state(), follow_singular_sensor ) @@ -351,6 +370,9 @@ async def verify_tip_presence( wrapping=[PythonException(e)], ) + def _get_hw_mount(self, pipette_id: str) -> Mount: + return self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + class VirtualTipHandler(TipHandler): """Pick up and drop tips, using a virtual pipette.""" @@ -414,13 +436,20 @@ async def drop_tip( expected_has_tip=True, ) - async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: - """Add a tip using a virtual pipette. + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: + """See documentation on abstract base class. This should not be called when using virtual pipettes. """ assert False, "TipHandler.cache_tip should not be used with virtual pipettes" + def remove_tip(self, pipette_id: str) -> None: + """See documentation on abstract base class. + + This should not be called when using virtual pipettes. + """ + assert False, "TipHandler.remove_tip should not be used with virtual pipettes" + async def verify_tip_presence( self, pipette_id: str, diff --git a/api/src/opentrons/protocol_engine/notes/__init__.py b/api/src/opentrons/protocol_engine/notes/__init__.py index f5b1d8c1a2a..606d75665a4 100644 --- a/api/src/opentrons/protocol_engine/notes/__init__.py +++ b/api/src/opentrons/protocol_engine/notes/__init__.py @@ -1,5 +1,17 @@ """Protocol engine notes module.""" -from .notes import NoteKind, CommandNote, CommandNoteAdder, CommandNoteTracker +from .notes import ( + NoteKind, + CommandNote, + CommandNoteAdder, + CommandNoteTracker, + make_error_recovery_debug_note, +) -__all__ = ["NoteKind", "CommandNote", "CommandNoteAdder", "CommandNoteTracker"] +__all__ = [ + "NoteKind", + "CommandNote", + "CommandNoteAdder", + "CommandNoteTracker", + "make_error_recovery_debug_note", +] diff --git a/api/src/opentrons/protocol_engine/notes/notes.py b/api/src/opentrons/protocol_engine/notes/notes.py index cf381aa4a68..8c349d167cd 100644 --- a/api/src/opentrons/protocol_engine/notes/notes.py +++ b/api/src/opentrons/protocol_engine/notes/notes.py @@ -1,7 +1,10 @@ """Definitions of data and interface shapes for notes.""" -from typing import Union, Literal, Protocol, List +from typing import Union, Literal, Protocol, List, TYPE_CHECKING from pydantic import BaseModel, Field +if TYPE_CHECKING: + from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType + NoteKind = Union[Literal["warning", "information"], str] @@ -26,6 +29,20 @@ class CommandNote(BaseModel): ) +def make_error_recovery_debug_note(type: "ErrorRecoveryType") -> CommandNote: + """Return a note for debugging error recovery. + + This is intended to be read by developers and support people, not computers. + """ + message = f"Handling this command failure with {type.name}." + return CommandNote.construct( + noteKind="debugErrorRecovery", + shortMessage=message, + longMessage=message, + source="execution", + ) + + class CommandNoteAdder(Protocol): """The shape of a function that something can use to add a command note.""" diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index d93ab5dd42d..ced32b20cc3 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -6,7 +6,6 @@ ResumeFromRecoveryAction, SetErrorRecoveryPolicyAction, ) -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.protocols.models import LabwareDefinition from opentrons.hardware_control import HardwareControlAPI @@ -19,6 +18,7 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError +from .error_recovery_policy import ErrorRecoveryPolicy from . import commands, slot_standardization from .resources import ModelUtils, ModuleDataProvider, FileProvider from .types import ( @@ -39,6 +39,7 @@ HardwareStopper, ) from .state.state import StateStore, StateView +from .state.update_types import StateUpdate from .plugins import AbstractPlugin, PluginStarter from .actions import ( ActionDispatcher, @@ -88,43 +89,31 @@ def __init__( self, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: Optional[ActionDispatcher] = None, - plugin_starter: Optional[PluginStarter] = None, + action_dispatcher: ActionDispatcher, + plugin_starter: PluginStarter, + model_utils: ModelUtils, + hardware_stopper: HardwareStopper, + door_watcher: DoorWatcher, + module_data_provider: ModuleDataProvider, + file_provider: FileProvider, queue_worker: Optional[QueueWorker] = None, - model_utils: Optional[ModelUtils] = None, - hardware_stopper: Optional[HardwareStopper] = None, - door_watcher: Optional[DoorWatcher] = None, - module_data_provider: Optional[ModuleDataProvider] = None, - file_provider: Optional[FileProvider] = None, ) -> None: """Initialize a ProtocolEngine instance. Must be called while an event loop is active. - This constructor does not inject provider implementations. + This constructor is only for `ProtocolEngine` unit tests. Prefer the `create_protocol_engine()` factory function. """ self._hardware_api = hardware_api - self._file_provider = file_provider or FileProvider() + self._file_provider = file_provider self._state_store = state_store - self._model_utils = model_utils or ModelUtils() - self._action_dispatcher = action_dispatcher or ActionDispatcher( - sink=self._state_store - ) - self._plugin_starter = plugin_starter or PluginStarter( - state=self._state_store, - action_dispatcher=self._action_dispatcher, - ) - self._hardware_stopper = hardware_stopper or HardwareStopper( - hardware_api=hardware_api, - state_store=state_store, - ) - self._door_watcher = door_watcher or DoorWatcher( - state_store=state_store, - hardware_api=hardware_api, - action_dispatcher=self._action_dispatcher, - ) - self._module_data_provider = module_data_provider or ModuleDataProvider() + self._model_utils = model_utils + self._action_dispatcher = action_dispatcher + self._plugin_starter = plugin_starter + self._hardware_stopper = hardware_stopper + self._door_watcher = door_watcher + self._module_data_provider = module_data_provider self._queue_worker = queue_worker if self._queue_worker: self._queue_worker.start() @@ -186,11 +175,35 @@ def request_pause(self) -> None: self._action_dispatcher.dispatch(action) self._hardware_api.pause(HardwarePauseType.PAUSE) - def resume_from_recovery(self) -> None: - """Resume normal protocol execution after the engine was `AWAITING_RECOVERY`.""" + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: + """Resume normal protocol execution after the engine was `AWAITING_RECOVERY`. + + If `reconcile_false_positive` is `False`, the engine will continue naively from + whatever state the error left it in. (Each defined error individually documents + exactly how it affects state.) This is appropriate for client-driven error + recovery, where the client wants predictable behavior from the engine. + + If `reconcile_false_positive` is `True`, the engine may apply additional fixups + to its state to try to get the rest of the run to just work, assuming the error + was a false-positive. + + For example, a `tipPhysicallyMissing` error from a `pickUpTip` would normally + leave the engine state without a tip on the pipette. If `reconcile_false_positive=True`, + the engine will set the pipette to have that missing tip before continuing, so + subsequent path planning, aspirates, dispenses, etc. will work as if nothing + went wrong. + """ + if reconcile_false_positive: + state_update = ( + self._state_store.commands.get_state_update_for_false_positive() + ) + else: + state_update = StateUpdate() # Empty/no-op. + action = self._state_store.commands.validate_action_allowed( - ResumeFromRecoveryAction() + ResumeFromRecoveryAction(state_update) ) + self._action_dispatcher.dispatch(action) def add_command( diff --git a/api/src/opentrons/protocol_engine/resources/file_provider.py b/api/src/opentrons/protocol_engine/resources/file_provider.py index d4ed7b71522..e1299605e76 100644 --- a/api/src/opentrons/protocol_engine/resources/file_provider.py +++ b/api/src/opentrons/protocol_engine/resources/file_provider.py @@ -5,7 +5,7 @@ from ..errors import StorageLimitReachedError -MAXIMUM_CSV_FILE_LIMIT = 40 +MAXIMUM_CSV_FILE_LIMIT = 400 class GenericCsvTransform: diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 6723c521892..4d2009aae80 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -25,6 +25,7 @@ ErrorRecoveryType, ) from opentrons.protocol_engine.notes.notes import CommandNote +from opentrons.protocol_engine.state import update_types from ..actions import ( Action, @@ -141,6 +142,16 @@ class CommandPointer: index: int +@dataclass(frozen=True) +class _RecoveryTargetInfo: + """Info about the failed command that we're currently recovering from.""" + + command_id: str + + state_update_if_false_positive: update_types.StateUpdate + """See `CommandView.get_state_update_if_continued()`.""" + + @dataclass class CommandState: """State of all protocol engine command resources.""" @@ -205,8 +216,8 @@ class CommandState: stable. Eventually, we might want this info to be stored directly on each command. """ - recovery_target_command_id: Optional[str] - """If we're currently recovering from a command failure, which command it was.""" + recovery_target: Optional[_RecoveryTargetInfo] + """If we're currently recovering from a command failure, info about that command.""" finish_error: Optional[ErrorOccurrence] """The error that happened during the post-run finish steps (homing & dropping tips), if any.""" @@ -253,7 +264,7 @@ def __init__( finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_completed_at=None, run_started_at=None, latest_protocol_command_hash=None, @@ -335,14 +346,17 @@ def _handle_succeed_command_action(self, action: SucceedCommandAction) -> None: def _handle_fail_command_action(self, action: FailCommandAction) -> None: prev_entry = self.state.command_history.get(action.command_id) - if isinstance(action.error, EnumeratedError): + if isinstance(action.error, EnumeratedError): # The error was undefined. public_error_occurrence = ErrorOccurrence.from_failed( id=action.error_id, createdAt=action.failed_at, error=action.error, ) - else: + # An empty state update, to no-op. + state_update_if_false_positive = update_types.StateUpdate() + else: # The error was defined. public_error_occurrence = action.error.public + state_update_if_false_positive = action.error.state_update_if_false_positive self._update_to_failed( command_id=action.command_id, @@ -354,6 +368,19 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: self._state.failed_command = self._state.command_history.get(action.command_id) self._state.failed_command_errors.append(public_error_occurrence) + if ( + prev_entry.command.intent in (CommandIntent.PROTOCOL, None) + and action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY + ): + self._state.queue_status = QueueStatus.AWAITING_RECOVERY + self._state.recovery_target = _RecoveryTargetInfo( + command_id=action.command_id, + state_update_if_false_positive=state_update_if_false_positive, + ) + self._state.has_entered_error_recovery = True + + # When one command fails, we generally also cancel the commands that + # would have been queued after it. other_command_ids_to_fail: List[str] if prev_entry.command.intent == CommandIntent.SETUP: other_command_ids_to_fail = list( @@ -373,7 +400,8 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: ) elif ( action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY - or action.type == ErrorRecoveryType.IGNORE_AND_CONTINUE + or action.type == ErrorRecoveryType.CONTINUE_WITH_ERROR + or action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE ): other_command_ids_to_fail = [] else: @@ -390,14 +418,6 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: notes=None, ) - if ( - prev_entry.command.intent in (CommandIntent.PROTOCOL, None) - and action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY - ): - self._state.queue_status = QueueStatus.AWAITING_RECOVERY - self._state.recovery_target_command_id = action.command_id - self._state.has_entered_error_recovery = True - def _handle_play_action(self, action: PlayAction) -> None: if not self._state.run_result: self._state.run_started_at = ( @@ -425,13 +445,13 @@ def _handle_resume_from_recovery_action( self, action: ResumeFromRecoveryAction ) -> None: self._state.queue_status = QueueStatus.RUNNING - self._state.recovery_target_command_id = None + self._state.recovery_target = None def _handle_stop_action(self, action: StopAction) -> None: if not self._state.run_result: - self._state.recovery_target_command_id = None - + self._state.recovery_target = None self._state.queue_status = QueueStatus.PAUSED + if action.from_estop: self._state.stopped_by_estop = True self._state.run_result = RunResult.FAILED @@ -440,7 +460,9 @@ def _handle_stop_action(self, action: StopAction) -> None: def _handle_finish_action(self, action: FinishAction) -> None: if not self._state.run_result: + self._state.recovery_target = None self._state.queue_status = QueueStatus.PAUSED + if action.set_run_status: self._state.run_result = ( RunResult.SUCCEEDED @@ -866,11 +888,11 @@ def get_all_commands_final(self) -> bool: def get_recovery_target(self) -> Optional[CommandPointer]: """Return the command currently undergoing error recovery, if any.""" - recovery_target_command_id = self._state.recovery_target_command_id - if recovery_target_command_id is None: + recovery_target = self._state.recovery_target + if recovery_target is None: return None else: - entry = self._state.command_history.get(recovery_target_command_id) + entry = self._state.command_history.get(recovery_target.command_id) return CommandPointer( command_id=entry.command.id, command_key=entry.command.key, @@ -1083,6 +1105,19 @@ def get_error_recovery_policy(self) -> ErrorRecoveryPolicy: """ return self._state.error_recovery_policy + def get_state_update_for_false_positive(self) -> update_types.StateUpdate: + """Return the state update for if the current recovery target was a false positive. + + If we're currently in error recovery mode, and you have decided that the + underlying command error was a false positive, this returns a state update + that will undo the error's effects on engine state. + See `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + """ + if self._state.recovery_target is None: + return update_types.StateUpdate() # Empty/no-op. + else: + return self._state.recovery_target.state_update_if_false_positive + def _may_run_with_door_open( self, *, fixit_command: Command | CommandCreate ) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 125be3339a9..471065adcc2 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -9,7 +9,6 @@ from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN -from opentrons_shared_data.labware.labware_definition import InnerWellGeometry from opentrons_shared_data.deck.types import CutoutFixture from opentrons_shared_data.pipette import PIPETTE_X_SPAN from opentrons_shared_data.pipette.types import ChannelCount @@ -1372,6 +1371,7 @@ def get_well_offset_adjustment( Distance is with reference to the well bottom. """ + # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions initial_handling_height = self.get_well_handling_height( labware_id=labware_id, well_name=well_name, @@ -1386,9 +1386,9 @@ def get_well_offset_adjustment( volume = operation_volume or 0.0 if volume: - well_geometry = self._labware.get_well_geometry(labware_id, well_name) return self.get_well_height_after_volume( - well_geometry=well_geometry, + labware_id=labware_id, + well_name=well_name, initial_height=initial_handling_height, volume=volume, ) @@ -1401,15 +1401,36 @@ def get_meniscus_height( well_name: str, ) -> float: """Returns stored meniscus height in specified well.""" - meniscus_height = self._wells.get_last_measured_liquid_height( + well_liquid = self._wells.get_well_liquid_info( labware_id=labware_id, well_name=well_name ) - if meniscus_height is None: - raise errors.LiquidHeightUnknownError( - "Must liquid probe before specifying WellOrigin.MENISCUS." + if ( + well_liquid.probed_height is not None + and well_liquid.probed_height.height is not None + ): + return well_liquid.probed_height.height + elif ( + well_liquid.loaded_volume is not None + and well_liquid.loaded_volume.volume is not None + ): + return self.get_well_height_at_volume( + labware_id=labware_id, + well_name=well_name, + volume=well_liquid.loaded_volume.volume, + ) + elif ( + well_liquid.probed_volume is not None + and well_liquid.probed_volume.volume is not None + ): + return self.get_well_height_at_volume( + labware_id=labware_id, + well_name=well_name, + volume=well_liquid.probed_volume.volume, ) else: - return meniscus_height + raise errors.LiquidHeightUnknownError( + "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS." + ) def get_well_handling_height( self, @@ -1431,12 +1452,15 @@ def get_well_handling_height( return float(handling_height) def get_well_height_after_volume( - self, well_geometry: InnerWellGeometry, initial_height: float, volume: float + self, labware_id: str, well_name: str, initial_height: float, volume: float ) -> float: """Return the height of liquid in a labware well after a given volume has been handled. This is given an initial handling height, with reference to the well bottom. """ + well_geometry = self._labware.get_well_geometry( + labware_id=labware_id, well_name=well_name + ) initial_volume = find_volume_at_well_height( target_height=initial_height, well_geometry=well_geometry ) @@ -1445,6 +1469,24 @@ def get_well_height_after_volume( target_volume=final_volume, well_geometry=well_geometry ) + def get_well_height_at_volume( + self, labware_id: str, well_name: str, volume: float + ) -> float: + """Convert well volume to height.""" + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + return find_height_at_well_volume( + target_volume=volume, well_geometry=well_geometry + ) + + def get_well_volume_at_height( + self, labware_id: str, well_name: str, height: float + ) -> float: + """Convert well height to volume.""" + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + return find_volume_at_well_height( + target_height=height, well_geometry=well_geometry + ) + def validate_dispense_volume_into_well( self, labware_id: str, @@ -1456,6 +1498,7 @@ def validate_dispense_volume_into_well( well_def = self._labware.get_well_definition(labware_id, well_name) well_volumetric_capacity = well_def.totalLiquidVolume if well_location.origin == WellOrigin.MENISCUS: + # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions well_geometry = self._labware.get_well_geometry(labware_id, well_name) meniscus_height = self.get_meniscus_height( labware_id=labware_id, well_name=well_name diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index dad9fe54dd0..7cea4f9765b 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -53,7 +53,7 @@ Action, AddLabwareOffsetAction, AddLabwareDefinitionAction, - get_state_update, + get_state_updates, ) from ._abstract_store import HasState, HandlesActions from ._move_types import EdgePathType @@ -149,8 +149,7 @@ def __init__( def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._add_loaded_labware(state_update) self._set_labware_location(state_update) diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index ced8b6076f7..bb90e067ec6 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -39,7 +39,7 @@ FailCommandAction, SetPipetteMovementSpeedAction, SucceedCommandAction, - get_state_update, + get_state_updates, ) from ._abstract_store import HasState, HandlesActions @@ -141,8 +141,7 @@ def __init__(self) -> None: def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._set_load_pipette(state_update) self._update_current_location(state_update) self._update_pipette_config(state_update) diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index b1c4dd8f766..7e47ccbbb37 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -6,12 +6,12 @@ from ..errors import ErrorOccurrence from ..types import ( EngineStatus, - LiquidHeightSummary, LoadedLabware, LabwareOffset, LoadedModule, LoadedPipette, Liquid, + WellInfoSummary, ) @@ -30,5 +30,5 @@ class StateSummary(BaseModel): startedAt: Optional[datetime] completedAt: Optional[datetime] liquids: List[Liquid] = Field(default_factory=list) - wells: List[LiquidHeightSummary] = Field(default_factory=list) + wells: List[WellInfoSummary] = Field(default_factory=list) files: List[str] = Field(default_factory=list) diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index f744b1a01b4..1ac3e91f795 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -6,12 +6,7 @@ from opentrons.protocol_engine.state import update_types from ._abstract_store import HasState, HandlesActions -from ..actions import Action, SucceedCommandAction, ResetTipsAction, get_state_update -from ..commands import ( - Command, - LoadLabwareResult, -) -from ..commands.configuring_common import PipetteConfigUpdateResultMixin +from ..actions import Action, ResetTipsAction, get_state_updates from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -63,23 +58,10 @@ def __init__(self) -> None: def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._handle_state_update(state_update) - if isinstance(action, SucceedCommandAction): - if isinstance(action.private_result, PipetteConfigUpdateResultMixin): - pipette_id = action.private_result.pipette_id - config = action.private_result.config - self._state.pipette_info_by_pipette_id[pipette_id] = _PipetteInfo( - channels=config.channels, - active_channels=config.channels, - nozzle_map=config.nozzle_map, - ) - - self._handle_succeeded_command(action.command) - - elif isinstance(action, ResetTipsAction): + if isinstance(action, ResetTipsAction): labware_id = action.labware_id for well_name in self._state.tips_by_labware_id[labware_id].keys(): @@ -87,23 +69,16 @@ def handle_action(self, action: Action) -> None: well_name ] = TipRackWellState.CLEAN - def _handle_succeeded_command(self, command: Command) -> None: - if ( - isinstance(command.result, LoadLabwareResult) - and command.result.definition.parameters.isTiprack - ): - labware_id = command.result.labwareId - definition = command.result.definition - self._state.tips_by_labware_id[labware_id] = { - well_name: TipRackWellState.CLEAN - for column in definition.ordering - for well_name in column - } - self._state.column_by_labware_id[labware_id] = [ - column for column in definition.ordering - ] - def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_config != update_types.NO_CHANGE: + self._state.pipette_info_by_pipette_id[ + state_update.pipette_config.pipette_id + ] = _PipetteInfo( + channels=state_update.pipette_config.config.channels, + active_channels=state_update.pipette_config.config.channels, + nozzle_map=state_update.pipette_config.config.nozzle_map, + ) + if state_update.tips_used != update_types.NO_CHANGE: self._set_used_tips( pipette_id=state_update.tips_used.pipette_id, @@ -120,6 +95,19 @@ def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: ) pipette_info.nozzle_map = state_update.pipette_nozzle_map.nozzle_map + if state_update.loaded_labware != update_types.NO_CHANGE: + labware_id = state_update.loaded_labware.labware_id + definition = state_update.loaded_labware.definition + if definition.parameters.isTiprack: + self._state.tips_by_labware_id[labware_id] = { + well_name: TipRackWellState.CLEAN + for column in definition.ordering + for well_name in column + } + self._state.column_by_labware_id[labware_id] = [ + column for column in definition.ordering + ] + def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 5d941d33933..181d8820723 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -4,6 +4,7 @@ import dataclasses import enum import typing +from datetime import datetime from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.protocol_engine.resources import pipette_data_provider @@ -175,6 +176,35 @@ class TipsUsedUpdate: """ +@dataclasses.dataclass +class LiquidLoadedUpdate: + """An update from loading a liquid.""" + + labware_id: str + volumes: typing.Dict[str, float] + last_loaded: datetime + + +@dataclasses.dataclass +class LiquidProbedUpdate: + """An update from probing a liquid.""" + + labware_id: str + well_name: str + last_probed: datetime + height: float | ClearType + volume: float | ClearType + + +@dataclasses.dataclass +class LiquidOperatedUpdate: + """An update from operating a liquid.""" + + labware_id: str + well_name: str + volume_added: float | ClearType + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -195,6 +225,12 @@ class StateUpdate: tips_used: TipsUsedUpdate | NoChangeType = NO_CHANGE + liquid_loaded: LiquidLoadedUpdate | NoChangeType = NO_CHANGE + + liquid_probed: LiquidProbedUpdate | NoChangeType = NO_CHANGE + + liquid_operated: LiquidOperatedUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a # complicated dataclass tree. @@ -330,3 +366,43 @@ def mark_tips_as_used( self.tips_used = TipsUsedUpdate( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + + def set_liquid_loaded( + self, + labware_id: str, + volumes: typing.Dict[str, float], + last_loaded: datetime, + ) -> None: + """Add liquid volumes to well state. See `LoadLiquidUpdate`.""" + self.liquid_loaded = LiquidLoadedUpdate( + labware_id=labware_id, + volumes=volumes, + last_loaded=last_loaded, + ) + + def set_liquid_probed( + self, + labware_id: str, + well_name: str, + last_probed: datetime, + height: float | ClearType, + volume: float | ClearType, + ) -> None: + """Add a liquid height and volume to well state. See `ProbeLiquidUpdate`.""" + self.liquid_probed = LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=height, + volume=volume, + last_probed=last_probed, + ) + + def set_liquid_operated( + self, labware_id: str, well_name: str, volume_added: float | ClearType + ) -> None: + """Update liquid volumes in well state. See `OperateLiquidUpdate`.""" + self.liquid_operated = LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=volume_added, + ) diff --git a/api/src/opentrons/protocol_engine/state/wells.py b/api/src/opentrons/protocol_engine/state/wells.py index d74d94a1be0..5b4d3bb8d77 100644 --- a/api/src/opentrons/protocol_engine/state/wells.py +++ b/api/src/opentrons/protocol_engine/state/wells.py @@ -1,25 +1,32 @@ """Basic well data state and store.""" from dataclasses import dataclass -from datetime import datetime -from typing import Dict, List, Optional -from opentrons.protocol_engine.actions.actions import ( - FailCommandAction, - SucceedCommandAction, +from typing import Dict, List, Union, Iterator, Optional, Tuple, overload, TypeVar + +from opentrons.protocol_engine.types import ( + ProbedHeightInfo, + ProbedVolumeInfo, + LoadedVolumeInfo, + WellInfoSummary, + WellLiquidInfo, ) -from opentrons.protocol_engine.commands.liquid_probe import LiquidProbeResult -from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError -from opentrons.protocol_engine.types import LiquidHeightInfo, LiquidHeightSummary +from . import update_types from ._abstract_store import HasState, HandlesActions from ..actions import Action -from ..commands import Command +from ..actions.get_state_update import get_state_updates + + +LabwareId = str +WellName = str @dataclass class WellState: """State of all wells.""" - measured_liquid_heights: Dict[str, Dict[str, LiquidHeightInfo]] + loaded_volumes: Dict[LabwareId, Dict[WellName, LoadedVolumeInfo]] + probed_heights: Dict[LabwareId, Dict[WellName, ProbedHeightInfo]] + probed_volumes: Dict[LabwareId, Dict[WellName, ProbedVolumeInfo]] class WellStore(HasState[WellState], HandlesActions): @@ -29,41 +36,95 @@ class WellStore(HasState[WellState], HandlesActions): def __init__(self) -> None: """Initialize a well store and its state.""" - self._state = WellState(measured_liquid_heights={}) + self._state = WellState(loaded_volumes={}, probed_heights={}, probed_volumes={}) def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - if isinstance(action, SucceedCommandAction): - self._handle_succeeded_command(action.command) - if isinstance(action, FailCommandAction): - self._handle_failed_command(action) - - def _handle_succeeded_command(self, command: Command) -> None: - if isinstance(command.result, LiquidProbeResult): - self._set_liquid_height( - labware_id=command.params.labwareId, - well_name=command.params.wellName, - height=command.result.z_position, - time=command.createdAt, - ) - - def _handle_failed_command(self, action: FailCommandAction) -> None: - if isinstance(action.error, LiquidNotFoundError): - self._set_liquid_height( - labware_id=action.error.private.labware_id, - well_name=action.error.private.well_name, - height=None, - time=action.failed_at, + for state_update in get_state_updates(action): + if state_update.liquid_loaded != update_types.NO_CHANGE: + self._handle_liquid_loaded_update(state_update.liquid_loaded) + if state_update.liquid_probed != update_types.NO_CHANGE: + self._handle_liquid_probed_update(state_update.liquid_probed) + if state_update.liquid_operated != update_types.NO_CHANGE: + self._handle_liquid_operated_update(state_update.liquid_operated) + + def _handle_liquid_loaded_update( + self, state_update: update_types.LiquidLoadedUpdate + ) -> None: + labware_id = state_update.labware_id + if labware_id not in self._state.loaded_volumes: + self._state.loaded_volumes[labware_id] = {} + for (well, volume) in state_update.volumes.items(): + self._state.loaded_volumes[labware_id][well] = LoadedVolumeInfo( + volume=_none_from_clear(volume), + last_loaded=state_update.last_loaded, + operations_since_load=0, ) - def _set_liquid_height( - self, labware_id: str, well_name: str, height: float, time: datetime + def _handle_liquid_probed_update( + self, state_update: update_types.LiquidProbedUpdate + ) -> None: + labware_id = state_update.labware_id + well_name = state_update.well_name + if labware_id not in self._state.probed_heights: + self._state.probed_heights[labware_id] = {} + if labware_id not in self._state.probed_volumes: + self._state.probed_volumes[labware_id] = {} + self._state.probed_heights[labware_id][well_name] = ProbedHeightInfo( + height=_none_from_clear(state_update.height), + last_probed=state_update.last_probed, + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=_none_from_clear(state_update.volume), + last_probed=state_update.last_probed, + operations_since_probe=0, + ) + + def _handle_liquid_operated_update( + self, state_update: update_types.LiquidOperatedUpdate ) -> None: - """Set the liquid height of the well.""" - lhi = LiquidHeightInfo(height=height, last_measured=time) - if labware_id not in self._state.measured_liquid_heights: - self._state.measured_liquid_heights[labware_id] = {} - self._state.measured_liquid_heights[labware_id][well_name] = lhi + labware_id = state_update.labware_id + well_name = state_update.well_name + if ( + labware_id in self._state.loaded_volumes + and well_name in self._state.loaded_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.loaded_volumes[labware_id][well_name] + else: + prev_loaded_vol_info = self._state.loaded_volumes[labware_id][well_name] + assert prev_loaded_vol_info.volume is not None + self._state.loaded_volumes[labware_id][well_name] = LoadedVolumeInfo( + volume=prev_loaded_vol_info.volume + state_update.volume_added, + last_loaded=prev_loaded_vol_info.last_loaded, + operations_since_load=prev_loaded_vol_info.operations_since_load + + 1, + ) + if ( + labware_id in self._state.probed_heights + and well_name in self._state.probed_heights[labware_id] + ): + del self._state.probed_heights[labware_id][well_name] + if ( + labware_id in self._state.probed_volumes + and well_name in self._state.probed_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.probed_volumes[labware_id][well_name] + else: + prev_probed_vol_info = self._state.probed_volumes[labware_id][well_name] + if prev_probed_vol_info.volume is None: + new_vol_info: float | None = None + else: + new_vol_info = ( + prev_probed_vol_info.volume + state_update.volume_added + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=new_vol_info, + last_probed=prev_probed_vol_info.last_probed, + operations_since_probe=prev_probed_vol_info.operations_since_probe + + 1, + ) class WellView(HasState[WellState]): @@ -79,51 +140,97 @@ def __init__(self, state: WellState) -> None: """ self._state = state - def get_all(self) -> List[LiquidHeightSummary]: - """Get all well liquid heights.""" - all_heights: List[LiquidHeightSummary] = [] - for labware, wells in self._state.measured_liquid_heights.items(): - for well, lhi in wells.items(): - lhs = LiquidHeightSummary( - labware_id=labware, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_all_in_labware(self, labware_id: str) -> List[LiquidHeightSummary]: - """Get all well liquid heights for a particular labware.""" - all_heights: List[LiquidHeightSummary] = [] - for well, lhi in self._state.measured_liquid_heights[labware_id].items(): - lhs = LiquidHeightSummary( - labware_id=labware_id, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_last_measured_liquid_height( - self, labware_id: str, well_name: str - ) -> Optional[float]: - """Returns the height of the liquid according to the most recent liquid level probe to this well. - - Returns None if no liquid probe has been done. - """ - try: - height = self._state.measured_liquid_heights[labware_id][well_name].height - return height - except KeyError: - return None - - def has_measured_liquid_height(self, labware_id: str, well_name: str) -> bool: - """Returns True if the well has been liquid level probed previously.""" - try: - return bool( - self._state.measured_liquid_heights[labware_id][well_name].height - ) - except KeyError: - return False + def get_well_liquid_info(self, labware_id: str, well_name: str) -> WellLiquidInfo: + """Return all the liquid info for a well.""" + if ( + labware_id not in self._state.loaded_volumes + or well_name not in self._state.loaded_volumes[labware_id] + ): + loaded_volume_info = None + else: + loaded_volume_info = self._state.loaded_volumes[labware_id][well_name] + if ( + labware_id not in self._state.probed_heights + or well_name not in self._state.probed_heights[labware_id] + ): + probed_height_info = None + else: + probed_height_info = self._state.probed_heights[labware_id][well_name] + if ( + labware_id not in self._state.probed_volumes + or well_name not in self._state.probed_volumes[labware_id] + ): + probed_volume_info = None + else: + probed_volume_info = self._state.probed_volumes[labware_id][well_name] + return WellLiquidInfo( + loaded_volume=loaded_volume_info, + probed_height=probed_height_info, + probed_volume=probed_volume_info, + ) + + def get_all(self) -> List[WellInfoSummary]: + """Get all well liquid info summaries.""" + + def _all_well_combos() -> Iterator[Tuple[str, str, str]]: + for labware, lv_wells in self._state.loaded_volumes.items(): + for well_name in lv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, ph_wells in self._state.probed_heights.items(): + for well_name in ph_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, pv_wells in self._state.probed_volumes.items(): + for well_name in pv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + + wells = { + key: (labware_id, well_name) + for key, labware_id, well_name in _all_well_combos() + } + return [ + self._summarize_well(labware_id, well_name) + for labware_id, well_name in wells.values() + ] + + def _summarize_well(self, labware_id: str, well_name: str) -> WellInfoSummary: + well_liquid_info = self.get_well_liquid_info(labware_id, well_name) + return WellInfoSummary( + labware_id=labware_id, + well_name=well_name, + loaded_volume=_volume_from_info(well_liquid_info.loaded_volume), + probed_volume=_volume_from_info(well_liquid_info.probed_volume), + probed_height=_height_from_info(well_liquid_info.probed_height), + ) + + +@overload +def _volume_from_info(info: Optional[ProbedVolumeInfo]) -> Optional[float]: + ... + + +@overload +def _volume_from_info(info: Optional[LoadedVolumeInfo]) -> Optional[float]: + ... + + +def _volume_from_info( + info: Union[ProbedVolumeInfo, LoadedVolumeInfo, None] +) -> Optional[float]: + if info is None: + return None + return info.volume + + +def _height_from_info(info: Optional[ProbedHeightInfo]) -> Optional[float]: + if info is None: + return None + return info.height + + +MaybeClear = TypeVar("MaybeClear") + + +def _none_from_clear(inval: MaybeClear | update_types.ClearType) -> MaybeClear | None: + if inval == update_types.CLEAR: + return None + return inval diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 72daafd3a52..ea3a57945b2 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -355,20 +355,46 @@ class CurrentWell: well_name: str -class LiquidHeightInfo(BaseModel): - """Payload required to store recent measured liquid heights.""" +class LoadedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LoadLiquid, updated by Aspirate and Dispense.""" - height: float - last_measured: datetime + volume: Optional[float] = None + last_loaded: datetime + operations_since_load: int -class LiquidHeightSummary(BaseModel): - """Payload for liquid state height in StateSummary.""" +class ProbedHeightInfo(BaseModel): + """A well's liquid height, initialized by a LiquidProbe, cleared by Aspirate and Dispense.""" + + height: Optional[float] = None + last_probed: datetime + + +class ProbedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LiquidProbe, updated by Aspirate and Dispense.""" + + volume: Optional[float] = None + last_probed: datetime + operations_since_probe: int + + +class WellInfoSummary(BaseModel): + """Payload for a well's liquid info in StateSummary.""" labware_id: str well_name: str - height: float - last_measured: datetime + loaded_volume: Optional[float] = None + probed_height: Optional[float] = None + probed_volume: Optional[float] = None + + +@dataclass +class WellLiquidInfo: + """Tracked and sensed information about liquid in a well.""" + + probed_height: Optional[ProbedHeightInfo] + loaded_volume: Optional[LoadedVolumeInfo] + probed_volume: Optional[ProbedVolumeInfo] @dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index 686560c1ca2..27b1c7ea331 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -271,7 +271,6 @@ def map_command( # noqa: C901 results.append( pe_actions.SucceedCommandAction( completed_command, - private_result=None, state_update=StateUpdate(), ) ) @@ -689,7 +688,6 @@ def _map_labware_load( succeed_action = pe_actions.SucceedCommandAction( command=succeeded_command, - private_result=None, state_update=state_update, ) @@ -731,7 +729,14 @@ def _map_instrument_load( result=pe_commands.LoadPipetteResult.construct(pipetteId=pipette_id), ) serial = instrument_load_info.pipette_dict.get("pipette_id", None) or "" - pipette_config_result = pe_commands.LoadPipettePrivateResult( + state_update = StateUpdate() + state_update.set_load_pipette( + pipette_id=pipette_id, + mount=succeeded_command.params.mount, + pipette_name=succeeded_command.params.pipetteName, + liquid_presence_detection=succeeded_command.params.liquidPresenceDetection, + ) + state_update.update_pipette_config( pipette_id=pipette_id, serial_number=serial, config=pipette_data_provider.get_pipette_static_config( @@ -754,16 +759,9 @@ def _map_instrument_load( # We just set this above, so we know it's not None. started_at=succeeded_command.startedAt, # type: ignore[arg-type] ) - state_update = StateUpdate() - state_update.set_load_pipette( - pipette_id=pipette_id, - mount=succeeded_command.params.mount, - pipette_name=succeeded_command.params.pipetteName, - liquid_presence_detection=succeeded_command.params.liquidPresenceDetection, - ) + succeed_action = pe_actions.SucceedCommandAction( command=succeeded_command, - private_result=pipette_config_result, state_update=state_update, ) @@ -829,7 +827,7 @@ def _map_module_load( started_at=succeeded_command.startedAt, # type: ignore[arg-type] ) succeed_action = pe_actions.SucceedCommandAction( - command=succeeded_command, private_result=None, state_update=StateUpdate() + command=succeeded_command, state_update=StateUpdate() ) self._command_count["LOAD_MODULE"] = count + 1 diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index dcf4f224811..aec2aae80df 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -123,9 +123,9 @@ async def stop(self) -> None: post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) - def resume_from_recovery(self) -> None: + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: """See `ProtocolEngine.resume_from_recovery()`.""" - self._protocol_engine.resume_from_recovery() + self._protocol_engine.resume_from_recovery(reconcile_false_positive) @abstractmethod async def run( diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 69d9feaf524..dfa66e6a55a 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -205,9 +205,9 @@ async def stop(self) -> None: post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) - def resume_from_recovery(self) -> None: + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: """Resume the run from recovery.""" - self._protocol_engine.resume_from_recovery() + self._protocol_engine.resume_from_recovery(reconcile_false_positive) async def finish( self, diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 0a36468f3bc..6520bb912f6 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,7 +5,7 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture -if ARCHITECTURE is SystemArchitecture.BUILDROOT: +if ARCHITECTURE is SystemArchitecture.YOCTO: from opentrons_hardware.sensors import SENSOR_LOG_NAME else: # we don't use the sensor log on ot2 or host diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index a5fadde09cc..fd537d4cad9 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -11,6 +11,7 @@ from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocol_api.core.engine.module_core import AbsorbanceReaderCore from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocol_engine.errors.exceptions import CannotPerformModuleAction from opentrons.protocol_engine.state.module_substates import AbsorbanceReaderSubState from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( AbsorbanceReaderId, @@ -67,6 +68,7 @@ def test_initialize( decoy: Decoy, mock_engine_client: EngineClient, subject: AbsorbanceReaderCore ) -> None: """It should set the sample wavelength with the engine client.""" + subject._ready_to_initialize = True subject.initialize("single", [123]) decoy.verify( @@ -115,10 +117,18 @@ def test_initialize( assert subject._initialized_value == [124, 125, 126] +def test_initialize_not_ready(subject: AbsorbanceReaderCore) -> None: + """It should raise CannotPerformModuleAction if you dont call .close_lid() command.""" + subject._ready_to_initialize = False + with pytest.raises(CannotPerformModuleAction): + subject.initialize("single", [123]) + + def test_read( decoy: Decoy, mock_engine_client: EngineClient, subject: AbsorbanceReaderCore ) -> None: """It should call absorbance reader to read with the engine client.""" + subject._ready_to_initialize = True subject._initialized_value = [123] substate = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(subject.module_id), diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 3d07bfe07d8..0ab9ac9da73 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -545,7 +545,7 @@ def test_aspirate_from_well( ) -def test_aspirate_from_location( +def test_aspirate_from_coordinates( decoy: Decoy, mock_engine_client: EngineClient, mock_protocol_core: ProtocolCore, @@ -583,6 +583,72 @@ def test_aspirate_from_location( ) +def test_aspirate_from_meniscus( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_protocol_core: ProtocolCore, + subject: InstrumentCore, +) -> None: + """It should aspirate from a well.""" + location = Location(point=Point(1, 2, 3), labware=None) + + well_core = WellCore( + name="my cool well", labware_id="123abc", engine_client=mock_engine_client + ) + + decoy.when( + mock_engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id="123abc", + well_name="my cool well", + absolute_point=Point(1, 2, 3), + is_meniscus=True, + ) + ).then_return( + LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, offset=WellOffset(x=3, y=2, z=1), volumeOffset=0 + ) + ) + + subject.aspirate( + location=location, + well_core=well_core, + volume=12.34, + rate=5.6, + flow_rate=7.8, + in_place=False, + is_meniscus=True, + ) + + decoy.verify( + pipette_movement_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + ), + mock_engine_client.execute_command( + cmd.AspirateParams( + pipetteId="abc123", + labwareId="123abc", + wellName="my cool well", + wellLocation=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + volume=12.34, + flowRate=7.8, + ) + ), + mock_protocol_core.set_last_location(location=location, mount=Mount.LEFT), + ) + + def test_aspirate_in_place( decoy: Decoy, mock_engine_client: EngineClient, diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py index 6ecf768c4eb..4145e1f0b5c 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py @@ -72,7 +72,6 @@ async def test_calibrate_gripper( result = await subject.execute(params) assert result == SuccessData( public=CalibrateGripperResult(jawOffset=Vec3f(x=1.1, y=2.2, z=3.3)), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py index 0226453c72e..0713bfa37d1 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py @@ -95,7 +95,6 @@ async def test_calibrate_module_implementation( ), location=location, ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py index ba949f0e2df..073db3bf295 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py @@ -64,7 +64,6 @@ async def test_calibrate_pipette_implementation( public=CalibratePipetteResult( pipetteOffset=InstrumentOffsetVector(x=3, y=4, z=6) ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py index ca8bb9de5bd..7051d1e44fc 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py @@ -56,7 +56,9 @@ async def test_calibration_move_to_location_implementation_for_attach_instrument decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) hw_mount = mount_type.to_hw_mount() decoy.verify( @@ -100,7 +102,9 @@ async def test_calibration_move_to_location_implementation_for_attach_plate( decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) decoy.verify( await ot3_hardware_api.prepare_for_mount_movement(Mount.LEFT), @@ -150,7 +154,9 @@ async def test_calibration_move_to_location_implementation_for_gripper( decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) decoy.verify( await ot3_hardware_api.prepare_for_mount_movement(Mount.LEFT), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py index fbd1fadcc23..d481ef33b9b 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py @@ -45,7 +45,7 @@ async def test_close_labware_latch( result = await subject.execute(data) decoy.verify(await heater_shaker_hardware.close_labware_latch(), times=1) assert result == SuccessData( - public=heater_shaker.CloseLabwareLatchResult(), private=None + public=heater_shaker.CloseLabwareLatchResult(), ) @@ -77,5 +77,5 @@ async def test_close_labware_latch_virtual( result = await subject.execute(data) assert result == SuccessData( - public=heater_shaker.CloseLabwareLatchResult(), private=None + public=heater_shaker.CloseLabwareLatchResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py index 5e8a65a06e8..6ce4336c9a3 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py @@ -46,5 +46,5 @@ async def test_deactivate_heater( result = await subject.execute(data) decoy.verify(await hs_hardware.deactivate_heater(), times=1) assert result == SuccessData( - public=heater_shaker.DeactivateHeaterResult(), private=None + public=heater_shaker.DeactivateHeaterResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py index db5e1aba138..466fa79dcc5 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py @@ -46,7 +46,7 @@ async def test_deactivate_shaker( result = await subject.execute(data) decoy.verify(await hs_hardware.deactivate_shaker(), times=1) assert result == SuccessData( - public=heater_shaker.DeactivateShakerResult(), private=None + public=heater_shaker.DeactivateShakerResult(), ) @@ -78,5 +78,5 @@ async def test_deactivate_shaker_virtual( result = await subject.execute(data) assert result == SuccessData( - public=heater_shaker.DeactivateShakerResult(), private=None + public=heater_shaker.DeactivateShakerResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py index 6a5e7e97db2..4b122f2d7e2 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py @@ -81,7 +81,6 @@ async def test_open_labware_latch( public=heater_shaker.OpenLabwareLatchResult( pipetteRetracted=expect_pipette_retracted ), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR) if expect_pipette_retracted else update_types.StateUpdate(), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py index 005f46f89cb..9db4bb27d00 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py @@ -84,7 +84,6 @@ async def test_set_and_wait_for_shake_speed( public=heater_shaker.SetAndWaitForShakeSpeedResult( pipetteRetracted=expect_pipette_retracted ), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR) if expect_pipette_retracted else update_types.StateUpdate(), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py index 51df5f560b3..977a76bfdf2 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py @@ -55,5 +55,5 @@ async def test_set_target_temperature( result = await subject.execute(data) decoy.verify(await hs_hardware.start_set_temperature(celsius=45.6), times=1) assert result == SuccessData( - public=heater_shaker.SetTargetTemperatureResult(), private=None + public=heater_shaker.SetTargetTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py index c256a480f16..f9804b90944 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py @@ -50,5 +50,5 @@ async def test_wait_for_temperature( await hs_hardware.await_temperature(awaiting_temperature=123.45), times=1 ) assert result == SuccessData( - public=heater_shaker.WaitForTemperatureResult(), private=None + public=heater_shaker.WaitForTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py index e1103518178..03d76db9e03 100644 --- a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py +++ b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py @@ -46,4 +46,4 @@ async def test_magnetic_module_disengage_implementation( result = await subject.execute(params=params) decoy.verify(await magnetic_module_hw.deactivate(), times=1) - assert result == SuccessData(public=DisengageResult(), private=None) + assert result == SuccessData(public=DisengageResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py index 5feddee3e2e..e1f14cb3f24 100644 --- a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py +++ b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py @@ -51,4 +51,4 @@ async def test_magnetic_module_engage_implementation( result = await subject.execute(params=params) decoy.verify(await magnetic_module_hw.engage(9001), times=1) - assert result == SuccessData(public=EngageResult(), private=None) + assert result == SuccessData(public=EngageResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py index dfe821c6bbb..91dc274f14c 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py @@ -45,5 +45,5 @@ async def test_await_temperature( result = await subject.execute(data) decoy.verify(await tempdeck_hardware.deactivate(), times=1) assert result == SuccessData( - public=temperature_module.DeactivateTemperatureResult(), private=None + public=temperature_module.DeactivateTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py index 0af71263e96..0bbd31f7a1d 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py @@ -52,5 +52,4 @@ async def test_set_target_temperature( decoy.verify(await tempdeck_hardware.start_set_temperature(celsius=1), times=1) assert result == SuccessData( public=temperature_module.SetTargetTemperatureResult(targetTemperature=1), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py index fb9456321b9..99e76f68774 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py @@ -48,7 +48,7 @@ async def test_wait_for_temperature( await tempdeck_hardware.await_temperature(awaiting_temperature=123), times=1 ) assert result == SuccessData( - public=temperature_module.WaitForTemperatureResult(), private=None + public=temperature_module.WaitForTemperatureResult(), ) @@ -90,5 +90,5 @@ async def test_wait_for_temperature_requested_celsius( await tempdeck_hardware.await_temperature(awaiting_temperature=12), times=1 ) assert result == SuccessData( - public=temperature_module.WaitForTemperatureResult(), private=None + public=temperature_module.WaitForTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py index 8d6f6d92179..102114b1cc8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -103,13 +103,17 @@ async def test_aspirate_implementation_no_prep( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) @@ -172,13 +176,17 @@ async def test_aspirate_implementation_with_prep( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) @@ -313,7 +321,12 @@ async def test_overpressure_error( labware_id=labware_id, well_name=well_name ), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=update_types.CLEAR, + ), ), ) @@ -329,14 +342,10 @@ async def test_aspirate_implementation_meniscus( ) -> None: """Aspirate should update WellVolumeOffset when called with WellOrigin.MENISCUS.""" location = LiquidHandlingWellLocation( - origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1) - ) - updated_location = LiquidHandlingWellLocation( origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1), volumeOffset="operationVolume", ) - data = AspirateParams( pipetteId="abc", labwareId="123", @@ -353,7 +362,7 @@ async def test_aspirate_implementation_meniscus( pipette_id="abc", labware_id="123", well_name="A3", - well_location=updated_location, + well_location=location, current_well=None, operation_volume=-50, ), @@ -372,12 +381,16 @@ async def test_aspirate_implementation_meniscus( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py index 3891dd90294..85d8f4fab84 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py @@ -21,6 +21,12 @@ from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, +) +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -61,6 +67,22 @@ def subject( ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) async def test_aspirate_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, @@ -68,6 +90,9 @@ async def test_aspirate_in_place_implementation( hardware_api: HardwareAPI, mock_command_note_adder: CommandNoteAdder, subject: AspirateInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should aspirate in place.""" data = AspirateInPlaceParams( @@ -91,9 +116,25 @@ async def test_aspirate_in_place_implementation( ) ).then_return(123) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + result = await subject.execute(params=data) - assert result == SuccessData(public=AspirateInPlaceResult(volume=123), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=-123, + ) + ), + ) + else: + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + ) async def test_handle_aspirate_in_place_request_not_ready_to_aspirate( @@ -153,6 +194,22 @@ async def test_aspirate_raises_volume_error( await subject.execute(data) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, @@ -160,6 +217,10 @@ async def test_overpressure_error( subject: AspirateInPlaceImplementation, model_utils: ModelUtils, mock_command_note_adder: CommandNoteAdder, + state_store: StateStore, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" pipette_id = "pipette-id" @@ -191,14 +252,32 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ) + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py index 93504c6904d..3e9aa6d82b8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py @@ -76,7 +76,6 @@ async def test_blow_out_implementation( assert result == SuccessData( public=BlowOutResult(position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py index e2735709253..49eced0670b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py @@ -52,7 +52,7 @@ async def test_blow_out_in_place_implementation( result = await subject.execute(data) - assert result == SuccessData(public=BlowOutInPlaceResult(), private=None) + assert result == SuccessData(public=BlowOutInPlaceResult()) decoy.verify( await pipetting.blow_out_in_place(pipette_id="pipette-id", flow_rate=1.234) diff --git a/api/tests/opentrons/protocol_engine/commands/test_comment.py b/api/tests/opentrons/protocol_engine/commands/test_comment.py index 4010f2ec56c..9b62afa7fe3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_comment.py +++ b/api/tests/opentrons/protocol_engine/commands/test_comment.py @@ -15,4 +15,4 @@ async def test_comment_implementation() -> None: result = await subject.execute(data) - assert result == SuccessData(public=CommentResult(), private=None) + assert result == SuccessData(public=CommentResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py b/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py index 2279f2a0ebf..d237c9e6090 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py @@ -19,7 +19,6 @@ from opentrons.protocol_engine.commands.configure_for_volume import ( ConfigureForVolumeParams, ConfigureForVolumeResult, - ConfigureForVolumePrivateResult, ConfigureForVolumeImplementation, ) from opentrons_shared_data.pipette.types import PipetteNameType @@ -85,9 +84,6 @@ async def test_configure_for_volume_implementation( assert result == SuccessData( public=ConfigureForVolumeResult(), - private=ConfigureForVolumePrivateResult( - pipette_id="pipette-id", serial_number="some number", config=config - ), state_update=StateUpdate( pipette_config=PipetteConfigUpdate( pipette_id="pipette-id", serial_number="some number", config=config diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py index e72b659a83c..cfe6f80c3a8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py @@ -145,7 +145,6 @@ async def test_configure_nozzle_layout_implementation( assert result == SuccessData( public=ConfigureNozzleLayoutResult(), - private=None, state_update=StateUpdate( pipette_nozzle_map=PipetteNozzleMapUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 167223e6d9d..a996e6915e8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -82,7 +82,6 @@ async def test_dispense_implementation( assert result == SuccessData( public=DispenseResult(volume=42, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id-abc123", @@ -92,6 +91,11 @@ async def test_dispense_implementation( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id-abc123", + well_name="A3", + volume_added=42, + ), ), ) @@ -161,5 +165,10 @@ async def test_overpressure_error( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=update_types.CLEAR, + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py index 53a491ad211..5e432bef80a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py @@ -1,6 +1,7 @@ """Test dispense-in-place commands.""" from datetime import datetime +import pytest from decoy import Decoy, matchers from opentrons_shared_data.errors.exceptions import PipetteOverpressureError @@ -16,17 +17,53 @@ ) from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.resources import ModelUtils - - +from opentrons.protocol_engine.state.state import StateStore +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, +) +from opentrons.protocol_engine.state import update_types + + +@pytest.fixture +def state_store(decoy: Decoy) -> StateStore: + """Get a mock in the shape of a StateStore.""" + return decoy.mock(cls=StateStore) + + +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) async def test_dispense_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, + state_store: StateStore, gantry_mover: GantryMover, model_utils: ModelUtils, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should dispense in place.""" subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + pipetting=pipetting, + state_view=state_store, + gantry_mover=gantry_mover, + model_utils=model_utils, ) data = DispenseInPlaceParams( @@ -41,20 +78,59 @@ async def test_dispense_in_place_implementation( ) ).then_return(42) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + result = await subject.execute(data) - assert result == SuccessData(public=DispenseInPlaceResult(volume=42), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=42, + ) + ), + ) + else: + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, pipetting: PipettingHandler, + state_store: StateStore, model_utils: ModelUtils, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + pipetting=pipetting, + state_view=state_store, + gantry_mover=gantry_mover, + model_utils=model_utils, ) pipette_id = "pipette-id" @@ -83,14 +159,32 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ) + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ) + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index 4a8e32c05d0..1d113c999c3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -129,7 +129,6 @@ async def test_drop_tip_implementation( assert result == SuccessData( public=DropTipResult(position=DeckPoint(x=111, y=222, z=333)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -207,7 +206,6 @@ async def test_drop_tip_with_alternating_locations( result = await subject.execute(params) assert result == SuccessData( public=DropTipResult(position=DeckPoint(x=111, y=222, z=333)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -294,4 +292,10 @@ async def test_tip_attached_error( new_deck_point=DeckPoint(x=111, y=222, z=333), ), ), + state_update_if_false_positive=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="abc", + tip_geometry=None, + ) + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py index f2061c3d552..292aa532879 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py @@ -49,7 +49,6 @@ async def test_success( assert result == SuccessData( public=DropTipInPlaceResult(), - private=None, state_update=StateUpdate( pipette_tip_state=PipetteTipStateUpdate(pipette_id="abc", tip_geometry=None) ), @@ -91,4 +90,7 @@ async def test_tip_attached_error( wrappedErrors=[matchers.Anything()], ), state_update=StateUpdate(), + state_update_if_false_positive=StateUpdate( + pipette_tip_state=PipetteTipStateUpdate(pipette_id="abc", tip_geometry=None) + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py b/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py index a1d0230f74a..99e5b231e1a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py +++ b/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py @@ -41,5 +41,5 @@ async def test_get_tip_presence_implementation( result = await subject.execute(data) assert result == SuccessData( - public=GetTipPresenceResult(status=status), private=None + public=GetTipPresenceResult(status=status), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_home.py b/api/tests/opentrons/protocol_engine/commands/test_home.py index 5a9446d6308..b3578c400e5 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_home.py +++ b/api/tests/opentrons/protocol_engine/commands/test_home.py @@ -24,7 +24,6 @@ async def test_home_implementation(decoy: Decoy, movement: MovementHandler) -> N assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.home(axes=[MotorAxis.X, MotorAxis.Y])) @@ -40,7 +39,6 @@ async def test_home_all_implementation(decoy: Decoy, movement: MovementHandler) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.home(axes=None)) @@ -63,7 +61,6 @@ async def test_home_with_invalid_position( result = await subject.execute(data) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) @@ -76,7 +73,6 @@ async def test_home_with_invalid_position( result = await subject.execute(data) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py index 6fb6ebc6935..2cada4f3e24 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -104,6 +104,7 @@ async def test_liquid_probe_implementation( subject: EitherImplementation, params_type: EitherParamsType, result_type: EitherResultType, + model_utils: ModelUtils, ) -> None: """It should move to the destination and do a liquid probe there.""" location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) @@ -137,18 +138,35 @@ async def test_liquid_probe_implementation( ), ).then_return(15.0) + decoy.when( + state_view.geometry.get_well_volume_at_height( + labware_id="123", + well_name="A3", + height=15.0, + ), + ).then_return(30.0) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) assert type(result.public) is result_type # Pydantic v1 only compares the fields. assert result == SuccessData( public=result_type(z_position=15.0, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="123", + well_name="A3", + height=15.0, + volume=30.0, + last_probed=timestamp, + ), ), ) @@ -212,7 +230,14 @@ async def test_liquid_not_found_error( pipette_id=pipette_id, new_location=update_types.Well(labware_id=labware_id, well_name=well_name), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=error_timestamp, + ), ) if isinstance(subject, LiquidProbeImplementation): assert result == DefinedErrorData( @@ -229,7 +254,6 @@ async def test_liquid_not_found_error( z_position=None, position=DeckPoint(x=position.x, y=position.y, z=position.z), ), - private=None, state_update=expected_state_update, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py index 85cb7794d76..3873f9854b4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py @@ -94,7 +94,6 @@ async def test_load_labware_implementation( definition=well_plate_def, offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id", @@ -178,7 +177,6 @@ async def test_load_labware_on_labware( definition=well_plate_def, offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py index 3ccaaea15d0..dbc584ae2a3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py @@ -1,6 +1,7 @@ """Test load-liquid command.""" import pytest from decoy import Decoy +from datetime import datetime from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands import ( @@ -9,6 +10,8 @@ LoadLiquidParams, ) from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -18,15 +21,18 @@ def mock_state_view(decoy: Decoy) -> StateView: @pytest.fixture -def subject(mock_state_view: StateView) -> LoadLiquidImplementation: +def subject( + mock_state_view: StateView, model_utils: ModelUtils +) -> LoadLiquidImplementation: """Load liquid implementation test subject.""" - return LoadLiquidImplementation(state_view=mock_state_view) + return LoadLiquidImplementation(state_view=mock_state_view, model_utils=model_utils) async def test_load_liquid_implementation( decoy: Decoy, subject: LoadLiquidImplementation, mock_state_view: StateView, + model_utils: ModelUtils, ) -> None: """Test LoadLiquid command execution.""" data = LoadLiquidParams( @@ -34,9 +40,22 @@ async def test_load_liquid_implementation( liquidId="liquid-id", volumeByWell={"A1": 30, "B2": 100}, ) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) - assert result == SuccessData(public=LoadLiquidResult(), private=None) + assert result == SuccessData( + public=LoadLiquidResult(), + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id="labware-id", + volumes={"A1": 30, "B2": 100}, + last_loaded=timestamp, + ) + ), + ) decoy.verify(mock_state_view.liquid.validate_liquid_id("liquid-id")) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_module.py b/api/tests/opentrons/protocol_engine/commands/test_load_module.py index 9479a724110..ce68f5c9f8a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_module.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_module.py @@ -92,7 +92,6 @@ async def test_load_module_implementation( model=ModuleModel.TEMPERATURE_MODULE_V2, definition=tempdeck_v2_def, ), - private=None, ) @@ -148,7 +147,6 @@ async def test_load_module_implementation_mag_block( model=ModuleModel.MAGNETIC_BLOCK_V1, definition=mag_block_v1_def, ), - private=None, ) @@ -204,7 +202,6 @@ async def test_load_module_implementation_abs_reader( model=ModuleModel.ABSORBANCE_READER_V1, definition=abs_reader_v1_def, ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py index 5884e015342..44a9db61863 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py @@ -22,7 +22,6 @@ from opentrons.protocol_engine.commands.load_pipette import ( LoadPipetteParams, LoadPipetteResult, - LoadPipettePrivateResult, LoadPipetteImplementation, ) from ..pipette_fixtures import get_default_nozzle_map @@ -90,9 +89,6 @@ async def test_load_pipette_implementation( assert result == SuccessData( public=LoadPipetteResult(pipetteId="some id"), - private=LoadPipettePrivateResult( - pipette_id="some id", serial_number="some-serial-number", config=config_data - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_name=PipetteNameType.P300_SINGLE, @@ -158,9 +154,6 @@ async def test_load_pipette_implementation_96_channel( assert result == SuccessData( public=LoadPipetteResult(pipetteId="pipette-id"), - private=LoadPipettePrivateResult( - pipette_id="pipette-id", serial_number="some id", config=config_data - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_name=PipetteNameType.P1000_96, diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py index aa02d85349a..a946eccf05d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py @@ -126,7 +126,6 @@ async def test_manual_move_labware_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( labware_id="my-cool-labware-id", @@ -192,7 +191,6 @@ async def test_move_labware_implementation_on_labware( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( labware_id="my-cool-labware-id", @@ -280,7 +278,6 @@ async def test_gripper_move_labware_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR, labware_location=update_types.LabwareLocationUpdate( @@ -516,7 +513,6 @@ async def test_gripper_move_to_waste_chute_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR, labware_location=update_types.LabwareLocationUpdate( diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_relative.py b/api/tests/opentrons/protocol_engine/commands/test_move_relative.py index ee874206f92..01522e4dc45 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_relative.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_relative.py @@ -38,7 +38,6 @@ async def test_move_relative_implementation( assert result == SuccessData( public=MoveRelativeResult(position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", 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 2b64f617b9f..6925fd7cce4 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 @@ -75,7 +75,6 @@ async def test_move_to_addressable_area_implementation_non_gen1( assert result == SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -139,7 +138,6 @@ async def test_move_to_addressable_area_implementation_with_gen1( assert result == SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py index ebcb3db1243..faca36d8121 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py @@ -58,7 +58,6 @@ async def test_move_to_addressable_area_for_drop_tip_implementation( assert result == SuccessData( public=MoveToAddressableAreaForDropTipResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py index 81d74657953..2e3ada1d3d3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py @@ -58,7 +58,6 @@ async def test_move_to_coordinates_implementation( assert result == SuccessData( public=MoveToCoordinatesResult(position=DeckPoint(x=4.44, y=5.55, z=6.66)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py index 1b01009dc0e..fdfcfb45af7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py @@ -61,7 +61,6 @@ async def test_move_to_well_implementation( assert result == SuccessData( public=MoveToWellResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py index 3771fe00eb1..00a3bc1c8a8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py @@ -2,6 +2,7 @@ from datetime import datetime from decoy import Decoy, matchers +from unittest.mock import sentinel from opentrons.types import MountType, Point @@ -11,7 +12,7 @@ WellOffset, DeckPoint, ) -from opentrons.protocol_engine.errors import TipNotAttachedError +from opentrons.protocol_engine.errors import PickUpTipTipNotAttachedError from opentrons.protocol_engine.execution import MovementHandler, TipHandler from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state import update_types @@ -83,7 +84,6 @@ async def test_success( tipDiameter=5, position=DeckPoint(x=111, y=222, z=333), ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -140,7 +140,7 @@ async def test_tip_physically_missing_error( await tip_handler.pick_up_tip( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) - ).then_raise(TipNotAttachedError()) + ).then_raise(PickUpTipTipNotAttachedError(tip_geometry=sentinel.tip_geometry)) decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_created_at) @@ -164,4 +164,9 @@ async def test_tip_physically_missing_error( pipette_id="pipette-id", labware_id="labware-id", well_name="well-name" ), ), + state_update_if_false_positive=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", tip_geometry=sentinel.tip_geometry + ) + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py index 45e8db96837..bb4f8c5f4d9 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py @@ -43,7 +43,7 @@ async def test_prepare_to_aspirate_implmenetation( ) result = await subject.execute(data) - assert result == SuccessData(public=PrepareToAspirateResult(), private=None) + assert result == SuccessData(public=PrepareToAspirateResult()) async def test_overpressure_error( diff --git a/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py b/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py index c79727c9a31..51779c427d7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py @@ -66,7 +66,6 @@ async def test_reload_labware_implementation( labwareId="my-labware-id", offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( labware_location=LabwareLocationUpdate( labware_id="my-labware-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py b/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py index 7442460f9b1..6dedf5b2f19 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py +++ b/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py @@ -25,7 +25,6 @@ async def test_retract_axis_implementation( assert result == SuccessData( public=RetractAxisResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.retract_axis(axis=MotorAxis.Y)) diff --git a/api/tests/opentrons/protocol_engine/commands/test_save_position.py b/api/tests/opentrons/protocol_engine/commands/test_save_position.py index c0f5e091e30..bc6d8ed6668 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_save_position.py +++ b/api/tests/opentrons/protocol_engine/commands/test_save_position.py @@ -51,5 +51,4 @@ async def test_save_position_implementation( positionId="456", position=DeckPoint(x=1, y=2, z=3), ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py b/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py index 161fb2d3fcf..956473f264f 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py +++ b/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py @@ -26,6 +26,6 @@ async def test_set_rail_lights_implementation( result = await subject.execute(data) - assert result == SuccessData(public=SetRailLightsResult(), private=None) + assert result == SuccessData(public=SetRailLightsResult()) decoy.verify(await rail_lights.set_rail_lights(True), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py b/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py index 53652ce6b87..41ae6703c61 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py +++ b/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py @@ -35,7 +35,7 @@ async def test_status_bar_busy( result = await subject.execute(params=data) - assert result == SuccessData(public=SetStatusBarResult(), private=None) + assert result == SuccessData(public=SetStatusBarResult()) decoy.verify(await status_bar.set_status_bar(status=StatusBarState.OFF), times=0) @@ -63,6 +63,6 @@ async def test_set_status_bar_animation( data = SetStatusBarParams(animation=animation) result = await subject.execute(params=data) - assert result == SuccessData(public=SetStatusBarResult(), private=None) + assert result == SuccessData(public=SetStatusBarResult()) decoy.verify(await status_bar.set_status_bar(status=expected_state), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py index f18e79e0a55..d00f44fd108 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py @@ -124,7 +124,6 @@ async def test_touch_tip_implementation( assert result == SuccessData( public=TouchTipResult(position=DeckPoint(x=4, y=5, z=6)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py b/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py index 087d924f0d2..53eb1f5a59e 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py +++ b/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py @@ -32,4 +32,4 @@ async def test_verify_tip_presence_implementation( result = await subject.execute(data) - assert result == SuccessData(public=VerifyTipPresenceResult(), private=None) + assert result == SuccessData(public=VerifyTipPresenceResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py b/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py index 9d351ce00d3..bc535a4b6a1 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py +++ b/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py @@ -22,5 +22,5 @@ async def test_pause_implementation( result = await subject.execute(data) - assert result == SuccessData(public=WaitForDurationResult(), private=None) + assert result == SuccessData(public=WaitForDurationResult()) decoy.verify(await run_control.wait_for_duration(42.0), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py b/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py index 752b85d3446..7d4b3a32edd 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py +++ b/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py @@ -23,7 +23,7 @@ async def test_wait_for_resume_implementation( result = await subject.execute(data) - assert result == SuccessData(public=WaitForResumeResult(), private=None) + assert result == SuccessData(public=WaitForResumeResult()) decoy.verify(await run_control.wait_for_resume(), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py index 0a3fc6e9fdf..9eb5536632d 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py @@ -58,6 +58,5 @@ async def test_close_lid( ) assert result == SuccessData( public=expected_result, - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py index 9f4ced905dc..676a11731a8 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py @@ -45,4 +45,4 @@ async def test_deactivate_block( result = await subject.execute(data) decoy.verify(await tc_hardware.deactivate_block(), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py index 1ea0e218c06..83fc347236b 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py @@ -45,4 +45,4 @@ async def test_deactivate_lid( result = await subject.execute(data) decoy.verify(await tc_hardware.deactivate_lid(), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py index a3e547a88d7..6c26b7d2877 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py @@ -56,6 +56,5 @@ async def test_open_lid( ) assert result == SuccessData( public=expected_result, - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py index 9dcefceb9f1..a4ed38a0dbf 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py @@ -112,4 +112,4 @@ async def test_run_extended_profile( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py index 9d3b79c66b1..6d6234a76e6 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py @@ -75,4 +75,4 @@ async def test_run_profile( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py index f05ac55c0ee..49d6dda3ca9 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py @@ -67,4 +67,4 @@ async def test_set_target_block_temperature( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py index b3565bc8a2d..372ae6a814c 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py @@ -56,4 +56,4 @@ async def test_set_target_lid_temperature( result = await subject.execute(data) decoy.verify(await tc_hardware.set_target_lid_temperature(celsius=45.6), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py index 880729bc149..426724cf16f 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py @@ -51,4 +51,4 @@ async def test_set_target_block_temperature( tc_module_substate.get_target_block_temperature(), await tc_hardware.wait_for_block_target(), ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py index 47b4b006342..e358e80d6f4 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py @@ -51,4 +51,4 @@ async def test_set_target_block_temperature( tc_module_substate.get_target_lid_temperature(), await tc_hardware.wait_for_lid_target(), ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py index 0130d7ce16b..72fb761ad23 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py @@ -45,7 +45,7 @@ async def test_engage_axes_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UnsafeEngageAxesResult(), private=None) + assert result == SuccessData(public=UnsafeEngageAxesResult()) decoy.verify( await ot3_hardware_api.engage_axes([Axis.Z_L, Axis.P_L, Axis.X, Axis.Y]), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py index 1a41244d556..a4eae34a08d 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py @@ -22,7 +22,7 @@ async def test_ungrip_labware_implementation( result = await subject.execute(params=UnsafeUngripLabwareParams()) - assert result == SuccessData(public=UnsafeUngripLabwareResult(), private=None) + assert result == SuccessData(public=UnsafeUngripLabwareResult()) decoy.verify( await ot3_hardware_api.ungrip(), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py index a40f914e049..aec5df2620d 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py @@ -41,7 +41,7 @@ async def test_blow_out_in_place_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + assert result == SuccessData(public=UnsafeBlowOutInPlaceResult()) decoy.verify( await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py index 4913fe3c444..fb23d96d987 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py @@ -50,7 +50,6 @@ async def test_drop_tip_implementation( assert result == SuccessData( public=UnsafeDropTipInPlaceResult(), - private=None, state_update=StateUpdate( pipette_tip_state=PipetteTipStateUpdate(pipette_id="abc", tip_geometry=None) ), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py index da7ffe75012..79131994299 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py @@ -45,7 +45,7 @@ async def test_update_position_estimators_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + assert result == SuccessData(public=UpdatePositionEstimatorsResult()) decoy.verify( await ot3_hardware_api.update_axis_position_estimations( diff --git a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py index f5f0ec063b0..eb84ceb018b 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -220,7 +220,7 @@ class _TestCommandDefinedError(ErrorOccurrence): _TestCommandReturn = Union[ - SuccessData[_TestCommandResult, None], + SuccessData[_TestCommandResult], DefinedErrorData[_TestCommandDefinedError], ] @@ -263,7 +263,7 @@ class _TestCommand( _ImplementationCls: Type[_TestCommandImpl] = TestCommandImplCls command_params = _TestCommandParams() - command_result = SuccessData(public=_TestCommandResult(), private=None) + command_result = SuccessData(public=_TestCommandResult()) queued_command = cast( Command, @@ -362,9 +362,7 @@ class _TestCommand( decoy.verify( action_dispatcher.dispatch( - SucceedCommandAction( - private_result=None, command=expected_completed_command - ) + SucceedCommandAction(command=expected_completed_command) ), ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py index d6c69d0b170..503d681bced 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py +++ b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py @@ -158,7 +158,7 @@ async def test_hardware_stopping_sequence_no_tip_drop( decoy.verify(await hardware_api.stop(home_after=False), times=1) decoy.verify( - await mock_tip_handler.cache_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -181,7 +181,7 @@ async def test_hardware_stopping_sequence_no_pipette( ) decoy.when( - await mock_tip_handler.cache_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -271,7 +271,7 @@ async def test_hardware_stopping_sequence_with_fixed_trash( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.cache_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -320,7 +320,7 @@ async def test_hardware_stopping_sequence_with_OT2_addressable_area( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.cache_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), diff --git a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index 8ddb8840597..c03a611966c 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -266,7 +266,7 @@ async def test_drop_tip( ) -async def test_add_tip( +def test_add_tip( decoy: Decoy, mock_state_view: StateView, mock_hardware_api: HardwareAPI, @@ -289,7 +289,7 @@ async def test_add_tip( MountType.LEFT ) - await subject.cache_tip(pipette_id="pipette-id", tip=tip) + subject.cache_tip(pipette_id="pipette-id", tip=tip) decoy.verify( mock_hardware_api.cache_tip(mount=Mount.LEFT, tip_length=50), @@ -301,6 +301,31 @@ async def test_add_tip( ) +def test_remove_tip( + decoy: Decoy, + mock_state_view: StateView, + mock_hardware_api: HardwareAPI, + mock_labware_data_provider: LabwareDataProvider, +) -> None: + """It should remove a tip manually from the hardware API.""" + subject = HardwareTipHandler( + state_view=mock_state_view, + hardware_api=mock_hardware_api, + labware_data_provider=mock_labware_data_provider, + ) + + decoy.when(mock_state_view.pipettes.get_mount("pipette-id")).then_return( + MountType.LEFT + ) + + subject.remove_tip(pipette_id="pipette-id") + + decoy.verify( + mock_hardware_api.remove_tip(Mount.LEFT), + mock_hardware_api.set_current_tiprack_diameter(Mount.LEFT, 0), + ) + + @pytest.mark.parametrize( argnames=[ "test_channels", diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 9c4665d31a2..5ac522095f2 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -1,7 +1,7 @@ """Command factories to use in tests as data fixtures.""" from datetime import datetime from pydantic import BaseModel -from typing import Optional, cast +from typing import Optional, cast, Dict from opentrons_shared_data.pipette.types import PipetteNameType from opentrons.types import MountType @@ -338,6 +338,29 @@ def create_liquid_probe_command( ) +def create_load_liquid_command( + liquid_id: str = "liquid-id", + labware_id: str = "labware-id", + volume_by_well: Dict[str, float] = {"A1": 30, "B2": 100}, +) -> cmd.LoadLiquid: + """Get a completed Load Liquid command.""" + params = cmd.LoadLiquidParams( + liquidId=liquid_id, + labwareId=labware_id, + volumeByWell=volume_by_well, + ) + result = cmd.LoadLiquidResult() + + return cmd.LoadLiquid( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) + + def create_pick_up_tip_command( pipette_id: str, labware_id: str = "labware-id", 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 b259e6a3f96..44c72e38e86 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 @@ -234,9 +234,7 @@ def test_addressable_area_referencing_commands_load_on_simulated_deck( simulated_subject: AddressableAreaStore, ) -> None: """It should check and store the addressable area when referenced in a command.""" - simulated_subject.handle_action( - SucceedCommandAction(private_result=None, command=command) - ) + simulated_subject.handle_action(SucceedCommandAction(command=command)) assert expected_area in simulated_subject.state.loaded_addressable_areas_by_name @@ -301,7 +299,7 @@ def test_addressable_area_referencing_commands_load( subject: AddressableAreaStore, ) -> None: """It should check that the addressable area is in the deck config.""" - subject.handle_action(SucceedCommandAction(private_result=None, command=command)) + subject.handle_action(SucceedCommandAction(command=command)) assert expected_area in subject.state.loaded_addressable_areas_by_name diff --git a/api/tests/opentrons/protocol_engine/state/test_command_state.py b/api/tests/opentrons/protocol_engine/state/test_command_state.py index 6f090612a74..fde0d66e654 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -19,7 +19,7 @@ PlayAction, SetErrorRecoveryPolicyAction, ) -from opentrons.protocol_engine.commands.command import CommandIntent +from opentrons.protocol_engine.commands.command import CommandIntent, DefinedErrorData from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence from opentrons.protocol_engine.errors.exceptions import ( @@ -32,6 +32,7 @@ CommandView, ) from opentrons.protocol_engine.state.config import Config +from opentrons.protocol_engine.state.update_types import StateUpdate from opentrons.protocol_engine.types import DeckType, EngineStatus @@ -772,7 +773,7 @@ def test_recovery_target_tracking() -> None: assert recovery_target.command_id == "c1" assert subject_view.get_recovery_in_progress_for_command("c1") - resume_from_1_recovery = actions.ResumeFromRecoveryAction() + resume_from_1_recovery = actions.ResumeFromRecoveryAction(StateUpdate()) subject.handle_action(resume_from_1_recovery) # c1 failed recoverably, but we've already completed its recovery. @@ -808,7 +809,7 @@ def test_recovery_target_tracking() -> None: # even though it failed recoverably before. assert not subject_view.get_recovery_in_progress_for_command("c1") - resume_from_2_recovery = actions.ResumeFromRecoveryAction() + resume_from_2_recovery = actions.ResumeFromRecoveryAction(StateUpdate()) subject.handle_action(resume_from_2_recovery) queue_3 = actions.QueueCommandAction( "c3", @@ -837,6 +838,58 @@ def test_recovery_target_tracking() -> None: assert subject_view.get_has_entered_recovery_mode() is True +@pytest.mark.parametrize( + "ending_action", + [ + actions.StopAction(from_estop=False), + actions.StopAction(from_estop=True), + actions.FinishAction(set_run_status=False), + actions.FinishAction( + set_run_status=True, + error_details=actions.FinishErrorDetails( + error=Exception("blimey"), + error_id="error-id", + created_at=datetime.now(), + ), + ), + ], +) +def test_recovery_target_clears_when_run_ends(ending_action: actions.Action) -> None: + """There should never be an error recovery target when the run is done.""" + subject = CommandStore( + config=_make_config(), + error_recovery_policy=_placeholder_error_recovery_policy, + is_door_open=False, + ) + subject_view = CommandView(subject.state) + + # Setup: Put the run in error recovery mode. + queue = actions.QueueCommandAction( + "c1", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue) + run = actions.RunCommandAction(command_id="c1", started_at=datetime.now()) + subject.handle_action(run) + fail = actions.FailCommandAction( + command_id="c1", + error_id="c1-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + running_command=subject_view.get("c1"), + ) + subject.handle_action(fail) + + # Test: Assert that the ending action clears the recovery target. + assert subject_view.get_recovery_target() is not None + subject.handle_action(ending_action) + assert subject_view.get_recovery_target() is None + + def test_final_state_after_estop() -> None: """Test the final state of the run after it's E-stopped.""" subject = CommandStore( @@ -993,3 +1046,57 @@ def test_set_and_get_error_recovery_policy() -> None: assert subject_view.get_error_recovery_policy() is initial_policy subject.handle_action(SetErrorRecoveryPolicyAction(sentinel.new_policy)) assert subject_view.get_error_recovery_policy() is new_policy + + +def test_get_state_update_for_false_positive() -> None: + """Test storage of false-positive state updates.""" + subject = CommandStore( + config=_make_config(), + error_recovery_policy=_placeholder_error_recovery_policy, + is_door_open=False, + ) + subject_view = CommandView(subject.state) + + empty_state_update = StateUpdate() + + assert subject_view.get_state_update_for_false_positive() == empty_state_update + + queue = actions.QueueCommandAction( + request=commands.CommentCreate( + params=commands.CommentParams(message=""), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-1", + ) + subject.handle_action(queue) + run = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run) + fail = actions.FailCommandAction( + command_id="command-id-1", + running_command=subject_view.get("command-id-1"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=DefinedErrorData( + public=sentinel.public, + state_update_if_false_positive=sentinel.state_update_if_false_positive, + ), + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + notes=[], + ) + subject.handle_action(fail) + + assert ( + subject_view.get_state_update_for_false_positive() + == sentinel.state_update_if_false_positive + ) + + resume_from_recovery = actions.ResumeFromRecoveryAction( + state_update=sentinel.some_other_state_update + ) + subject.handle_action(resume_from_recovery) + + assert subject_view.get_state_update_for_false_positive() == empty_state_update diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py index 4b7cf01e87c..d5f171b7ea9 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -84,7 +84,6 @@ def test_command_queue_and_unqueue() -> None: started_at=datetime(year=2022, month=2, day=2), ) succeed_2 = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-2"), ) @@ -137,7 +136,6 @@ def test_setup_command_queue_and_unqueue() -> None: command_id="command-id-2", started_at=datetime(year=2022, month=2, day=2) ) succeed_2 = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-2"), ) @@ -214,7 +212,6 @@ def test_running_command_id() -> None: started_at=datetime(year=2021, month=1, day=1), ) succeed = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-1"), ) @@ -303,7 +300,6 @@ def test_command_store_keeps_commands_in_queue_order() -> None: command=create_succeeded_command( command_id="command-id-2", ), - private_result=None, ) ) assert subject.state.command_history.get_all_ids() == [ @@ -334,7 +330,7 @@ def test_command_store_handles_pause_action(pause_source: PauseSource) -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, latest_protocol_command_hash=None, stopped_by_estop=False, failed_command_errors=[], @@ -363,7 +359,7 @@ def test_command_store_handles_play_action(pause_source: PauseSource) -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -398,7 +394,7 @@ def test_command_store_handles_finish_action() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -453,7 +449,7 @@ def test_command_store_handles_stop_action( finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=from_estop, @@ -491,7 +487,7 @@ def test_command_store_handles_stop_action_when_awaiting_recovery() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -525,7 +521,7 @@ def test_command_store_cannot_restart_after_should_stop() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, @@ -673,7 +669,7 @@ def test_command_store_wraps_unknown_errors() -> None: run_started_at=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, latest_protocol_command_hash=None, stopped_by_estop=False, failed_command_errors=[], @@ -742,7 +738,7 @@ def __init__(self, message: str) -> None: ), failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, @@ -778,7 +774,7 @@ def test_command_store_ignores_stop_after_graceful_finish() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -814,7 +810,7 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -850,7 +846,7 @@ def test_handles_hardware_stopped() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index 06318cb8d36..f7b1d6cd31f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -22,6 +22,9 @@ from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.state.commands import ( + # todo(mm, 2024-10-24): Avoid testing internal implementation details like + # _RecoveryTargetInfo. See note above about porting to test_command_state.py. + _RecoveryTargetInfo, CommandState, CommandView, CommandSlice, @@ -38,6 +41,7 @@ from opentrons_shared_data.errors.codes import ErrorCodes from opentrons.protocol_engine.state.command_history import CommandHistory +from opentrons.protocol_engine.state.update_types import StateUpdate from .command_fixtures import ( create_queued_command, @@ -108,7 +112,12 @@ def get_command_view( # noqa: C901 finish_error=finish_error, failed_command=failed_command, command_error_recovery_types=command_error_recovery_types or {}, - recovery_target_command_id=recovery_target_command_id, + recovery_target=_RecoveryTargetInfo( + command_id=recovery_target_command_id, + state_update_if_false_positive=StateUpdate(), + ) + if recovery_target_command_id is not None + else None, run_started_at=run_started_at, latest_protocol_command_hash=latest_command_hash, stopped_by_estop=False, @@ -592,7 +601,7 @@ class ActionAllowedSpec(NamedTuple): ), ), ), - action=ResumeFromRecoveryAction(), + action=ResumeFromRecoveryAction(StateUpdate()), expected_error=errors.ResumeFromRecoveryNotAllowedError, ), ActionAllowedSpec( diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 7a94f06ca09..3f7ad59bda2 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -55,6 +55,9 @@ LoadedPipette, TipGeometry, ModuleDefinition, + ProbedHeightInfo, + LoadedVolumeInfo, + WellLiquidInfo, ) from opentrons.protocol_engine.commands import ( CommandStatus, @@ -1539,9 +1542,13 @@ def test_get_well_position_with_meniscus_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(70.5) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + probed_volume=None, + probed_height=ProbedHeightInfo(height=70.5, last_probed=datetime.now()), + loaded_volume=None, + ) + ) decoy.when( mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") ).then_return(0.5) @@ -1563,6 +1570,68 @@ def test_get_well_position_with_meniscus_offset( ) +def test_get_well_position_with_volume_offset_raises_error( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """Calling get_well_position with any volume offset should raise an error when there's no innerLabwareGeometry.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_raise( + errors.IncompleteLabwareDefinitionError("Woops!") + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + volumeOffset="operationVolume", + ), + operation_volume=-1245.833, + pipette_id="pipette-id", + ) + + def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -1597,9 +1666,13 @@ def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1663,9 +1736,13 @@ def test_get_well_position_with_meniscus_and_float_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1728,9 +1805,13 @@ def test_get_well_position_raises_validation_error( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1755,6 +1836,76 @@ def test_get_well_position_raises_validation_error( ) +def test_get_meniscus_height( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should be able to get the position of a well meniscus in a labware.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=LoadedVolumeInfo( + volume=2000.0, last_loaded=datetime.now(), operations_since_load=0 + ), + probed_height=None, + probed_volume=None, + ) + ) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + result = subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=WellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + ), + pipette_id="pipette-id", + ) + + assert result == Point( + x=slot_pos[0] + 1 + well_def.x + 2, + y=slot_pos[1] - 2 + well_def.y + 3, + z=slot_pos[2] + 3 + well_def.z + 4 + 39.2423, + ) + + def test_get_relative_well_location( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -2786,7 +2937,6 @@ def test_get_offset_location_deck_slot( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2831,7 +2981,6 @@ def test_get_offset_location_module( model=tempdeck_v2_def.model, ), ), - private_result=None, ) load_labware = SucceedCommandAction( command=LoadLabware( @@ -2851,7 +3000,6 @@ def test_get_offset_location_module( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2900,7 +3048,6 @@ def test_get_offset_location_module_with_adapter( model=tempdeck_v2_def.model, ), ), - private_result=None, ) load_adapter = SucceedCommandAction( command=LoadLabware( @@ -2920,7 +3067,6 @@ def test_get_offset_location_module_with_adapter( version=nice_adapter_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="adapter-id-1", @@ -2949,7 +3095,6 @@ def test_get_offset_location_module_with_adapter( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2998,7 +3143,6 @@ def test_get_offset_fails_with_off_deck_labware( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -3133,9 +3277,13 @@ def test_validate_dispense_volume_into_well_meniscus( decoy.when(mock_labware_view.get_well_geometry("labware-id", "A1")).then_return( inner_well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "A1") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "A1")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) with pytest.raises(errors.InvalidDispenseVolumeError): subject.validate_dispense_volume_into_well( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_store.py b/api/tests/opentrons/protocol_engine/state/test_labware_store.py index 68c7e86c5ff..47150ec425f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_store.py @@ -126,7 +126,6 @@ def test_handles_load_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -154,7 +153,6 @@ def test_handles_reload_labware( subject.handle_action( SucceedCommandAction( - private_result=None, command=command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -194,7 +192,6 @@ def test_handles_reload_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -254,7 +251,6 @@ def test_handles_move_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -273,7 +269,6 @@ def test_handles_move_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -311,7 +306,6 @@ def test_handles_move_labware_off_deck( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -330,7 +324,6 @@ def test_handles_move_labware_off_deck( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index d461ddda4e6..d6b05b7b027 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -460,6 +460,17 @@ def test_get_well_definition_get_first(well_plate_def: LabwareDefinition) -> Non assert result == expected_well_def +def test_get_well_geometry_raises_error(well_plate_def: LabwareDefinition) -> None: + """It should raise an IncompleteLabwareDefinitionError when there's no innerLabwareGeometry.""" + subject = get_labware_view( + labware_by_id={"plate-id": plate}, + definitions_by_uri={"some-plate-uri": well_plate_def}, + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_geometry(labware_id="plate-id") + + def test_get_well_size_circular(well_plate_def: LabwareDefinition) -> None: """It should return the well dimensions of a circular well.""" subject = get_labware_view( diff --git a/api/tests/opentrons/protocol_engine/state/test_module_store.py b/api/tests/opentrons/protocol_engine/state/test_module_store.py index 4f94ed314d5..832713ed0a4 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_store.py @@ -197,7 +197,6 @@ def test_load_module( ) -> None: """It should handle a successful LoadModule command.""" action = actions.SucceedCommandAction( - private_result=None, command=commands.LoadModule.construct( # type: ignore[call-arg] params=commands.LoadModuleParams( model=params_model, @@ -261,7 +260,6 @@ def test_load_thermocycler_in_thermocycler_slot( ) -> None: """It should update additional slots for thermocycler module.""" action = actions.SucceedCommandAction( - private_result=None, command=commands.LoadModule.construct( # type: ignore[call-arg] params=commands.LoadModuleParams( model=ModuleModel.THERMOCYCLER_MODULE_V2, @@ -411,12 +409,8 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -425,9 +419,7 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) plate_target_temperature=42, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -465,12 +457,8 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_shake_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_shake_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -479,9 +467,7 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non plate_target_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -521,9 +507,7 @@ def test_handle_hs_labware_latch_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -533,9 +517,7 @@ def test_handle_hs_labware_latch_commands( ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=close_latch_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=close_latch_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -544,9 +526,7 @@ def test_handle_hs_labware_latch_commands( plate_target_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=open_latch_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=open_latch_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -588,20 +568,14 @@ def test_handle_tempdeck_temperature_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": TemperatureModuleSubState( module_id=TemperatureModuleId("module-id"), plate_target_temperature=42 ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": TemperatureModuleSubState( module_id=TemperatureModuleId("module-id"), plate_target_temperature=None @@ -650,12 +624,8 @@ def test_handle_thermocycler_temperature_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_block_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_block_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -664,9 +634,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_lid_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=set_lid_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -675,9 +643,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=35.3, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -686,9 +652,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_block_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_block_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -734,12 +698,8 @@ def test_handle_thermocycler_lid_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=open_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=open_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -749,9 +709,7 @@ def test_handle_thermocycler_lid_commands( ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=close_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=close_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index caab429e26b..c8eab566abe 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -81,16 +81,13 @@ def test_location_state_update(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.RIGHT, ) - subject.handle_action( - SucceedCommandAction(command=load_command, private_result=None) - ) + subject.handle_action(SucceedCommandAction(command=load_command)) # Update the location to a well: dummy_command = create_succeeded_command() subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -120,7 +117,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -143,7 +139,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -162,7 +157,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -181,7 +175,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) ) @@ -233,7 +226,6 @@ def test_handles_load_pipette( subject.handle_action( SucceedCommandAction( - private_result=None, command=dummy_command, state_update=update_types.StateUpdate( loaded_pipette=load_pipette_update, pipette_config=config_update @@ -271,7 +263,6 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -286,7 +277,6 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -303,7 +293,6 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=drop_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -334,7 +323,6 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -348,7 +336,6 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -365,7 +352,6 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=drop_tip_in_place_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -396,7 +382,6 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -410,7 +395,6 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -427,7 +411,6 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=unsafe_drop_tip_in_place_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -461,7 +444,6 @@ def test_aspirate_adds_volume( subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -473,15 +455,11 @@ def test_aspirate_adds_volume( ), ) ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) - ) + subject.handle_action(SucceedCommandAction(command=aspirate_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] == 42 - subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) - ) + subject.handle_action(SucceedCommandAction(command=aspirate_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] == 84 @@ -514,7 +492,6 @@ def test_dispense_subtracts_volume( subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -526,18 +503,12 @@ def test_dispense_subtracts_volume( ), ) ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) - ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=dispense_command) - ) + subject.handle_action(SucceedCommandAction(command=aspirate_command)) + subject.handle_action(SucceedCommandAction(command=dispense_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] == 21 - subject.handle_action( - SucceedCommandAction(private_result=None, command=dispense_command) - ) + subject.handle_action(SucceedCommandAction(command=dispense_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] == 0 @@ -567,7 +538,6 @@ def test_blow_out_clears_volume( subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -579,12 +549,8 @@ def test_blow_out_clears_volume( ), ) ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) - ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=blow_out_command) - ) + subject.handle_action(SucceedCommandAction(command=aspirate_command)) + subject.handle_action(SucceedCommandAction(command=blow_out_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] is None @@ -597,9 +563,7 @@ def test_set_movement_speed(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=load_pipette_command) - ) + subject.handle_action(SucceedCommandAction(command=load_pipette_command)) subject.handle_action( SetPipetteMovementSpeedAction(pipette_id=pipette_id, speed=123.456) ) @@ -638,13 +602,9 @@ def test_add_pipette_config( pipette_lld_settings={}, ) - private_result = cmd.LoadPipettePrivateResult( - pipette_id="pipette-id", serial_number="pipette-serial", config=config - ) subject.handle_action( SucceedCommandAction( command=command, - private_result=private_result, state_update=update_types.StateUpdate( pipette_config=update_types.PipetteConfigUpdate( pipette_id="pipette-id", @@ -705,7 +665,6 @@ def test_prepare_to_aspirate_marks_pipette_ready( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -719,7 +678,6 @@ def test_prepare_to_aspirate_marks_pipette_ready( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( @@ -732,7 +690,6 @@ def test_prepare_to_aspirate_marks_pipette_ready( subject.handle_action( SucceedCommandAction( - private_result=None, command=previous, ) ) @@ -740,7 +697,5 @@ def test_prepare_to_aspirate_marks_pipette_ready( prepare_to_aspirate_command = create_prepare_to_aspirate_command( pipette_id="pipette-id" ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=prepare_to_aspirate_command) - ) + subject.handle_action(SucceedCommandAction(command=prepare_to_aspirate_command)) assert subject.state.aspirated_volume_by_id["pipette-id"] == 0.0 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 e0f0fd15669..abb408d7418 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -16,11 +16,11 @@ from opentrons.protocol_engine import actions, commands from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.tips import TipStore, TipView -from opentrons.protocol_engine.types import FlowRates +from opentrons.protocol_engine.types import DeckSlotLocation, FlowRates from opentrons.protocol_engine.resources.pipette_data_provider import ( LoadedStaticPipetteData, ) -from opentrons.types import Point +from opentrons.types import DeckSlotName, Point from opentrons_shared_data.pipette.types import PipetteNameType from ..pipette_fixtures import ( NINETY_SIX_MAP, @@ -61,13 +61,21 @@ def labware_definition() -> LabwareDefinition: @pytest.fixture -def load_labware_command(labware_definition: LabwareDefinition) -> commands.LoadLabware: +def load_labware_action( + labware_definition: LabwareDefinition, +) -> actions.SucceedCommandAction: """Get a load labware command value object.""" - return commands.LoadLabware.construct( # type: ignore[call-arg] - result=commands.LoadLabwareResult.construct( - labwareId="cool-labware", - definition=labware_definition, - ) + return actions.SucceedCommandAction( + command=_dummy_command(), + state_update=update_types.StateUpdate( + loaded_labware=update_types.LoadedLabwareUpdate( + labware_id="cool-labware", + definition=labware_definition, + new_location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A1), + display_name=None, + offset_id=None, + ) + ), ) @@ -83,18 +91,13 @@ def _dummy_command() -> commands.Command: ], ) def test_get_next_tip_returns_none( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start at the first tip in the labware.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -120,7 +123,8 @@ def test_get_next_tip_returns_none( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -136,18 +140,14 @@ def test_get_next_tip_returns_none( @pytest.mark.parametrize("input_tip_amount", [1, 8, 96]) def test_get_next_tip_returns_first_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start at the first tip in the labware.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) + subject.handle_action(load_labware_action) + pipette_name_type = PipetteNameType.P1000_96 if input_tip_amount == 1: pipette_name_type = PipetteNameType.P300_SINGLE_GEN2 @@ -155,7 +155,7 @@ def test_get_next_tip_returns_first_tip( pipette_name_type = PipetteNameType.P300_MULTI_GEN2 else: pipette_name_type = PipetteNameType.P1000_96 - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -181,7 +181,8 @@ def test_get_next_tip_returns_first_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -197,20 +198,16 @@ def test_get_next_tip_returns_first_tip( @pytest.mark.parametrize("input_tip_amount, result_well_name", [(1, "B1"), (8, "A2")]) def test_get_next_tip_used_starting_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, result_well_name: str, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start searching at the given starting tip.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -236,7 +233,8 @@ def test_get_next_tip_used_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -265,7 +263,7 @@ def test_get_next_tip_used_starting_tip( ], ) def test_get_next_tip_skips_picked_up_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, get_next_tip_tips: int, @@ -274,13 +272,8 @@ def test_get_next_tip_skips_picked_up_tip( supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should get the next tip in the column if one has been picked up.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) channels_num = input_tip_amount if input_starting_tip is not None: pipette_name_type = PipetteNameType.P1000_96 @@ -299,7 +292,7 @@ def test_get_next_tip_skips_picked_up_tip( pipette_name_type = PipetteNameType.P300_MULTI_GEN2 else: pipette_name_type = PipetteNameType.P1000_96 - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -325,7 +318,8 @@ def test_get_next_tip_skips_picked_up_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -339,7 +333,6 @@ def test_get_next_tip_skips_picked_up_tip( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -348,7 +341,7 @@ def test_get_next_tip_skips_picked_up_tip( labware_id="cool-labware", num_tips=get_next_tip_tips, starting_tip_name=input_starting_tip, - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == result_well_name @@ -356,17 +349,13 @@ def test_get_next_tip_skips_picked_up_tip( def test_get_next_tip_with_starting_tip( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip, and then the following tip after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -392,14 +381,15 @@ def test_get_next_tip_with_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) result = TipView(subject.state).get_next_tip( labware_id="cool-labware", num_tips=1, starting_tip_name="B2", - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == "B2" @@ -410,7 +400,6 @@ def test_get_next_tip_with_starting_tip( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -419,7 +408,7 @@ def test_get_next_tip_with_starting_tip( labware_id="cool-labware", num_tips=1, starting_tip_name="B2", - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == "C2" @@ -427,17 +416,13 @@ def test_get_next_tip_with_starting_tip( def test_get_next_tip_with_starting_tip_8_channel( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip, and then the following tip after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -463,7 +448,8 @@ def test_get_next_tip_with_starting_tip_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -484,7 +470,6 @@ def test_get_next_tip_with_starting_tip_8_channel( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -501,17 +486,13 @@ def test_get_next_tip_with_starting_tip_8_channel( def test_get_next_tip_with_1_channel_followed_by_8_channel( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the first tip of column 2 for the 8 channel after performing a single tip pickup on column 1.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -537,13 +518,12 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) - load_pipette_command_2 = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id2") - ) - load_pipette_private_result_2 = commands.LoadPipettePrivateResult( + + config_update_2 = update_types.PipetteConfigUpdate( pipette_id="pipette-id2", serial_number="pipette-serial2", config=LoadedStaticPipetteData( @@ -569,7 +549,8 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result_2, command=load_pipette_command_2 + state_update=update_types.StateUpdate(pipette_config=config_update_2), + command=_dummy_command(), ) ) @@ -590,7 +571,6 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_2_state_update, ) ) @@ -607,17 +587,13 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( def test_get_next_tip_with_starting_tip_out_of_tips( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip of H12 and then None after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -643,7 +619,8 @@ def test_get_next_tip_with_starting_tip_out_of_tips( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -664,7 +641,6 @@ def test_get_next_tip_with_starting_tip_out_of_tips( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -681,17 +657,13 @@ def test_get_next_tip_with_starting_tip_out_of_tips( def test_get_next_tip_with_column_and_starting_tip( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the first tip in a column, taking starting tip into account.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -717,7 +689,8 @@ def test_get_next_tip_with_column_and_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -733,17 +706,13 @@ def test_get_next_tip_with_column_and_starting_tip( def test_reset_tips( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should be able to reset tip tracking state.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -770,14 +739,14 @@ def test_reset_tips( subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=update_types.StateUpdate( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", @@ -805,10 +774,7 @@ def test_handle_pipette_config_action( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition ) -> None: """Should add pipette channel to state.""" - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -834,7 +800,8 @@ def test_handle_pipette_config_action( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -852,12 +819,10 @@ def test_handle_pipette_config_action( ], ) def test_has_tip_not_tip_rack( - load_labware_command: commands.LoadLabware, subject: TipStore + load_labware_action: actions.SucceedCommandAction, subject: TipStore ) -> None: """It should return False if labware isn't a tip rack.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) result = TipView(state=subject.state).has_clean_tip("cool-labware", "A1") @@ -865,12 +830,10 @@ def test_has_tip_not_tip_rack( def test_has_tip_tip_rack( - load_labware_command: commands.LoadLabware, subject: TipStore + load_labware_action: actions.SucceedCommandAction, subject: TipStore ) -> None: """It should return False if labware isn't a tip rack.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) result = TipView(state=subject.state).has_clean_tip("cool-labware", "A1") @@ -944,10 +907,7 @@ def test_active_channels( ) -> None: """Should update active channels after pipette configuration change.""" # Load pipette to update state - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -973,7 +933,8 @@ def test_active_channels( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -987,7 +948,6 @@ def test_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1000,19 +960,14 @@ def test_active_channels( def test_next_tip_uses_active_channels( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> None: """Test that tip tracking logic uses pipette's active channels.""" # Load labware - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1038,7 +993,8 @@ def test_next_tip_uses_active_channels( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1073,7 +1029,6 @@ def test_next_tip_uses_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1081,7 +1036,6 @@ def test_next_tip_uses_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=update_types.StateUpdate( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", @@ -1104,19 +1058,14 @@ def test_next_tip_uses_active_channels( def test_next_tip_automatic_tip_tracking_with_partial_configurations( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> None: """Test tip tracking logic using multiple pipette configurations.""" # Load labware - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1142,7 +1091,8 @@ def test_next_tip_automatic_tip_tracking_with_partial_configurations( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1166,7 +1116,6 @@ def _assert_and_pickup(well: str, nozzle_map: NozzleMap) -> None: subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -1237,7 +1186,6 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1262,19 +1210,14 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM def test_next_tip_automatic_tip_tracking_tiprack_limits( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> 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) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1300,7 +1243,8 @@ def test_next_tip_automatic_tip_tracking_tiprack_limits( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1321,7 +1265,6 @@ def _get_next_and_pickup(nozzle_map: NozzleMap) -> str | None: subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -1365,7 +1308,7 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM ) subject.handle_action( actions.SucceedCommandAction( - command=_dummy_command(), private_result=None, state_update=state_update + command=_dummy_command(), state_update=state_update ) ) return nozzle_map diff --git a/api/tests/opentrons/protocol_engine/state/test_well_store.py b/api/tests/opentrons/protocol_engine/state/test_well_store.py index 325021a9942..ec59a643db0 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_store.py @@ -1,9 +1,15 @@ """Well state store tests.""" import pytest +from datetime import datetime from opentrons.protocol_engine.state.wells import WellStore from opentrons.protocol_engine.actions.actions import SucceedCommandAction +from opentrons.protocol_engine.state import update_types -from .command_fixtures import create_liquid_probe_command +from .command_fixtures import ( + create_liquid_probe_command, + create_load_liquid_command, + create_aspirate_command, +) @pytest.fixture @@ -16,13 +22,208 @@ def test_handles_liquid_probe_success(subject: WellStore) -> None: """It should add the well to the state after a successful liquid probe.""" labware_id = "labware-id" well_name = "well-name" + liquid_probe = create_liquid_probe_command() + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + + assert len(subject.state.probed_heights) == 1 + assert len(subject.state.probed_volumes) == 1 + + assert subject.state.probed_heights[labware_id][well_name].height == 15.0 + assert subject.state.probed_heights[labware_id][well_name].last_probed == timestamp + assert subject.state.probed_volumes[labware_id][well_name].volume == 30.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 0 + ) + + +def test_handles_load_liquid_success(subject: WellStore) -> None: + """It should add the well to the state after a successful load liquid.""" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 30.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 0 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 100.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 0 + ) + + +def test_handles_load_liquid_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after load liquid and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + aspirated_volume = 10.0 + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + aspirate_1 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_1, + ) + aspirate_2 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_2, + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_1, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_1, + volume_added=-aspirated_volume, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_2, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_2, + volume_added=-aspirated_volume, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 20.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 1 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 90.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 1 + ) + + +def test_handles_liquid_probe_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after liquid probe and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name = "well-name" + aspirated_volume = 10.0 liquid_probe = create_liquid_probe_command() + aspirate = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name, + ) + timestamp = datetime(year=2020, month=1, day=2) subject.handle_action( - SucceedCommandAction(private_result=None, command=liquid_probe) + SucceedCommandAction( + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=-aspirated_volume, + ) + ), + ) ) - assert len(subject.state.measured_liquid_heights) == 1 + assert len(subject.state.probed_heights[labware_id]) == 0 + assert len(subject.state.probed_volumes) == 1 - assert subject.state.measured_liquid_heights[labware_id][well_name].height == 0.5 + assert subject.state.probed_volumes[labware_id][well_name].volume == 20.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 1 + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_well_view.py b/api/tests/opentrons/protocol_engine/state/test_well_view.py index 3bd86e9dcb9..5025e4ee93e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_view.py @@ -1,6 +1,10 @@ """Well view tests.""" from datetime import datetime -from opentrons.protocol_engine.types import LiquidHeightInfo +from opentrons.protocol_engine.types import ( + LoadedVolumeInfo, + ProbedHeightInfo, + ProbedVolumeInfo, +) import pytest from opentrons.protocol_engine.state.wells import WellState, WellView @@ -8,44 +12,47 @@ @pytest.fixture def subject() -> WellView: """Get a well view test subject.""" - labware_id = "labware-id" - well_name = "well-name" - height_info = LiquidHeightInfo(height=0.5, last_measured=datetime.now()) - state = WellState(measured_liquid_heights={labware_id: {well_name: height_info}}) + loaded_volume_info = LoadedVolumeInfo( + volume=30.0, last_loaded=datetime.now(), operations_since_load=0 + ) + probed_height_info = ProbedHeightInfo(height=5.5, last_probed=datetime.now()) + probed_volume_info = ProbedVolumeInfo( + volume=25.0, last_probed=datetime.now(), operations_since_probe=0 + ) + state = WellState( + loaded_volumes={"labware_id_1": {"well_name": loaded_volume_info}}, + probed_heights={"labware_id_2": {"well_name": probed_height_info}}, + probed_volumes={"labware_id_2": {"well_name": probed_volume_info}}, + ) return WellView(state) -def test_get_all(subject: WellView) -> None: - """Should return a list of well heights.""" - assert subject.get_all()[0].height == 0.5 - - -def test_get_last_measured_liquid_height(subject: WellView) -> None: - """Should return the height of a single well correctly for valid wells.""" - labware_id = "labware-id" - well_name = "well-name" - - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" - - assert ( - subject.get_last_measured_liquid_height(invalid_labware_id, invalid_well_name) - is None +def test_get_well_liquid_info(subject: WellView) -> None: + """Should return a tuple of well infos.""" + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_1", well_name="well_name" ) - assert subject.get_last_measured_liquid_height(labware_id, well_name) == 0.5 + assert volume_info.loaded_volume is not None + assert volume_info.probed_height is None + assert volume_info.probed_volume is None + assert volume_info.loaded_volume.volume == 30.0 + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_2", well_name="well_name" + ) + assert volume_info.loaded_volume is None + assert volume_info.probed_height is not None + assert volume_info.probed_volume is not None + assert volume_info.probed_height.height == 5.5 + assert volume_info.probed_volume.volume == 25.0 -def test_has_measured_liquid_height(subject: WellView) -> None: - """Should return True for measured wells and False for ones that have no measurements.""" - labware_id = "labware-id" - well_name = "well-name" - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" +def test_get_all(subject: WellView) -> None: + """Should return a list of well summaries.""" + summaries = subject.get_all() - assert ( - subject.has_measured_liquid_height(invalid_labware_id, invalid_well_name) - is False - ) - assert subject.has_measured_liquid_height(labware_id, well_name) is True + assert len(summaries) == 2, f"{summaries}" + assert summaries[0].loaded_volume == 30.0 + assert summaries[1].probed_height == 5.5 + assert summaries[1].probed_volume == 25.0 diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 71e23cfe715..ac83e987153 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -10,6 +10,7 @@ from opentrons_shared_data.robot.types import RobotType from opentrons.protocol_engine.actions.actions import SetErrorRecoveryPolicyAction +from opentrons.protocol_engine.state.update_types import StateUpdate from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, OT2HardwareControlAPI from opentrons.hardware_control.modules import MagDeck, TempDeck @@ -38,7 +39,11 @@ HardwareStopper, DoorWatcher, ) -from opentrons.protocol_engine.resources import ModelUtils, ModuleDataProvider +from opentrons.protocol_engine.resources import ( + FileProvider, + ModelUtils, + ModuleDataProvider, +) from opentrons.protocol_engine.state.config import Config from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.plugins import AbstractPlugin, PluginStarter @@ -118,6 +123,12 @@ def module_data_provider(decoy: Decoy) -> ModuleDataProvider: return decoy.mock(cls=ModuleDataProvider) +@pytest.fixture +def file_provider(decoy: Decoy) -> FileProvider: + """Get a mock FileProvider.""" + return decoy.mock(cls=FileProvider) + + @pytest.fixture(autouse=True) def _mock_slot_standardization_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch @@ -148,6 +159,7 @@ def subject( hardware_stopper: HardwareStopper, door_watcher: DoorWatcher, module_data_provider: ModuleDataProvider, + file_provider: FileProvider, ) -> ProtocolEngine: """Get a ProtocolEngine test subject with its dependencies stubbed out.""" return ProtocolEngine( @@ -160,6 +172,7 @@ def subject( hardware_stopper=hardware_stopper, door_watcher=door_watcher, module_data_provider=module_data_provider, + file_provider=file_provider, ) @@ -613,20 +626,31 @@ def test_pause( ) +@pytest.mark.parametrize("reconcile_false_positive", [True, False]) def test_resume_from_recovery( decoy: Decoy, state_store: StateStore, action_dispatcher: ActionDispatcher, subject: ProtocolEngine, + reconcile_false_positive: bool, ) -> None: """It should dispatch a ResumeFromRecoveryAction.""" - expected_action = ResumeFromRecoveryAction() + decoy.when(state_store.commands.get_state_update_for_false_positive()).then_return( + sentinel.state_update_for_false_positive + ) + empty_state_update = StateUpdate() + + expected_action = ResumeFromRecoveryAction( + sentinel.state_update_for_false_positive + if reconcile_false_positive + else empty_state_update + ) decoy.when( state_store.commands.validate_action_allowed(expected_action) ).then_return(expected_action) - subject.resume_from_recovery() + subject.resume_from_recovery(reconcile_false_positive) decoy.verify(action_dispatcher.dispatch(expected_action)) diff --git a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py index 8663c3e0a8d..42c589ba7d3 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py @@ -6,6 +6,7 @@ from opentrons.protocol_engine.state.update_types import ( LoadPipetteUpdate, LoadedLabwareUpdate, + PipetteConfigUpdate, StateUpdate, ) import pytest @@ -116,7 +117,6 @@ def test_map_after_command() -> None: assert result == [ pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.Comment.construct( id="command.COMMENT-0", key="command.COMMENT-0", @@ -240,7 +240,6 @@ def test_command_stack() -> None: command_id="command.COMMENT-1", started_at=matchers.IsA(datetime) ), pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.Comment.construct( id="command.COMMENT-0", key="command.COMMENT-0", @@ -320,7 +319,6 @@ def test_map_labware_load(minimal_labware_def: LabwareDefinition) -> None: ), notes=[], ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-0", @@ -380,16 +378,18 @@ def test_map_instrument_load(decoy: Decoy) -> None: result=pe_commands.LoadPipetteResult(pipetteId="pipette-0"), notes=[], ), - private_result=pe_commands.LoadPipettePrivateResult( - pipette_id="pipette-0", serial_number="fizzbuzz", config=pipette_config - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_id="pipette-0", mount=expected_params.mount, pipette_name=expected_params.pipetteName, liquid_presence_detection=expected_params.liquidPresenceDetection, - ) + ), + pipette_config=PipetteConfigUpdate( + pipette_id="pipette-0", + serial_number="fizzbuzz", + config=pipette_config, + ), ), ) @@ -456,7 +456,6 @@ def test_map_module_load( ), notes=[], ), - private_result=None, ) [result_queue, result_run, result_succeed] = LegacyCommandMapper( @@ -521,7 +520,6 @@ def test_map_module_labware_load(minimal_labware_def: LabwareDefinition) -> None ), notes=[], ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-0", @@ -580,7 +578,6 @@ def test_map_pause() -> None: started_at=matchers.IsA(datetime), ), pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.WaitForResume.construct( id="command.PAUSE-0", key="command.PAUSE-0", diff --git a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py index 1714064bfa5..0ccc616012a 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py @@ -163,18 +163,14 @@ async def test_command_broker_messages( decoy.when( mock_legacy_command_mapper.map_command(command=legacy_command) - ).then_return( - [pe_actions.SucceedCommandAction(engine_command, private_result=None)] - ) + ).then_return([pe_actions.SucceedCommandAction(engine_command)]) await to_thread.run_sync(handler, legacy_command) await subject.teardown() decoy.verify( - mock_action_dispatcher.dispatch( - pe_actions.SucceedCommandAction(engine_command, private_result=None) - ) + mock_action_dispatcher.dispatch(pe_actions.SucceedCommandAction(engine_command)) ) @@ -222,9 +218,7 @@ async def test_equipment_broker_messages( decoy.when( mock_legacy_command_mapper.map_equipment_load(load_info=load_info) - ).then_return( - [pe_actions.SucceedCommandAction(command=engine_command, private_result=None)] - ) + ).then_return([pe_actions.SucceedCommandAction(command=engine_command)]) await to_thread.run_sync(handler, load_info) @@ -232,6 +226,6 @@ async def test_equipment_broker_messages( decoy.verify( mock_action_dispatcher.dispatch( - pe_actions.SucceedCommandAction(command=engine_command, private_result=None) + pe_actions.SucceedCommandAction(command=engine_command) ), ) diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index cd945c33e64..2f06e27c2c2 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -313,9 +313,16 @@ def test_resume_from_recovery( subject: AnyRunner, ) -> None: """It should call `resume_from_recovery()` on the underlying engine.""" - subject.resume_from_recovery() + subject.resume_from_recovery( + reconcile_false_positive=sentinel.reconcile_false_positive + ) - decoy.verify(protocol_engine.resume_from_recovery(), times=1) + decoy.verify( + protocol_engine.resume_from_recovery( + reconcile_false_positive=sentinel.reconcile_false_positive + ), + times=1, + ) async def test_run_json_runner( diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 27ff23e0909..a093e29e6ed 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -8,6 +8,30 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons App Changes in 8.2.0 + +Welcome to the v8.2.0 release of the Opentrons App! This release adds support for the Opentrons Absorbance Plate Reader Module, as well as other features. + +### New Features + +- Run protocols that use the Absorbance Plate Reader and check the status of the module on the robot details screen for your Flex. +- Run protocols that use the new Opentrons Tough PCR Auto-Sealing Lid with the Thermocycler Module GEN2. Stacks of these lids appear in a consolidated view when setting up labware. + +### Improved Features + +- Error recovery now works in more situations and has more options. + - Recover from gripper errors. + - Recover from failure to drop tips. + - Indicate that an error was improperly detected and skip similar errors later in the run. + - Choose from more options of where to drop tips as part of recovery. + - Disable error recovery entirely, if your application requires it. Runs will fail on any error. + +### Bug Fixes + +- Fixed an app crash when performing certain error recovery steps with Python API version 2.15 protocols. + +--- + ## Opentrons App Changes in 8.1.0 Welcome to the v8.1.0 release of the Opentrons App! diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 7be1070ca12..2e2217ce20e 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -191,9 +191,9 @@ export const OnDeviceDisplayApp = (): JSX.Element => { ) : ( <> - + diff --git a/app/src/assets/images/absorbance_reader_instruction_manual_code.png b/app/src/assets/images/absorbance_reader_instruction_manual_code.png new file mode 100644 index 00000000000..70c45ab3b56 Binary files /dev/null and b/app/src/assets/images/absorbance_reader_instruction_manual_code.png differ diff --git a/app/src/assets/images/labware/opentrons_flex_deck_riser.png b/app/src/assets/images/labware/opentrons_flex_deck_riser.png new file mode 100644 index 00000000000..a06f26bf445 Binary files /dev/null and b/app/src/assets/images/labware/opentrons_flex_deck_riser.png differ diff --git a/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png b/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png new file mode 100644 index 00000000000..bc0cffa3df6 Binary files /dev/null and b/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png differ diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index 5640f3306a5..6dbee9af16f 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -1,7 +1,7 @@ { - "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_close_lid": "Closing Absorbance Reader lid", "absorbance_reader_initialize": "Initializing Absorbance Reader to perform {{mode}} measurement at {{wavelengths}}", + "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_read": "Reading plate in Absorbance Reader", "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", "adapter_in_slot": "{{adapter}} in {{slot}}", @@ -27,6 +27,7 @@ "dispense_push_out": "Dispensing {{volume}} µL into well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec and pushing out {{push_out_volume}} µL", "drop_tip": "Dropping tip in {{well_name}} of {{labware}}", "drop_tip_in_place": "Dropping tip in place", + "dropping_tip_in_trash": "Dropping tip in {{trash}}", "engaging_magnetic_module": "Engaging Magnetic Module", "fixed_trash": "Fixed Trash", "home_gantry": "Homing all gantry, pipette, and plunger axes", @@ -73,16 +74,16 @@ "setting_thermocycler_lid_temp": "Setting Thermocycler lid temperature to {{temp}}", "single": "single", "slot": "Slot {{slot_name}}", - "turning_rail_lights_off": "Turning rail lights off", - "turning_rail_lights_on": "Turning rail lights on", "target_temperature": "target temperature", "tc_awaiting_for_duration": "Waiting for Thermocycler profile to complete", "tc_run_profile_steps": "Temperature: {{celsius}}°C, hold time: {{duration}}", - "tc_starting_extended_profile_cycle": "{{repetitions}} repetitions of the following steps:", "tc_starting_extended_profile": "Running thermocycler profile with {{elementCount}} total steps and cycles:", + "tc_starting_extended_profile_cycle": "{{repetitions}} repetitions of the following steps:", "tc_starting_profile": "Running thermocycler profile with {{stepCount}} steps:", "touch_tip": "Touching tip", "trash_bin_in_slot": "Trash Bin in {{slot_name}}", + "turning_rail_lights_off": "Turning rail lights off", + "turning_rail_lights_on": "Turning rail lights on", "unlatching_hs_latch": "Unlatching labware on Heater-Shaker", "wait_for_duration": "Pausing for {{seconds}} seconds. {{message}}", "wait_for_resume": "Pausing protocol", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index f2e284e607e..17f60958d55 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -120,6 +120,7 @@ "labware_position_check_step_description": "Recommended workflow that helps you verify the position of each labware on the deck.", "labware_position_check_step_title": "Labware Position Check", "labware_position_check_text": "Labware Position Check is a recommended workflow that helps you verify the position of each labware on the deck. During this check, you can create Labware Offsets that adjust how the robot moves to each labware in the X, Y and Z directions.", + "labware_quantity": "Quantity: {{quantity}}", "labware_setup_step_description": "Gather the following labware and full tip racks. To run your protocol without Labware Position Check, place and secure labware in their initial locations.", "labware_setup_step_title": "Labware", "last_calibrated": "Last calibrated: {{date}}", @@ -159,6 +160,7 @@ "module_connected": "Connected", "module_disconnected": "Disconnected", "module_instructions_link": "{{moduleName}} setup instructions", + "module_instructions_manual": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to read the module Instruction Manual.", "module_mismatch_body": "Check that the modules connected to this robot are of the right type and generation", "module_name": "Module", "module_not_connected": "Not connected", diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx b/app/src/local-resources/commands/hooks/useCommandTextString/index.ts similarity index 98% rename from app/src/local-resources/commands/hooks/useCommandTextString/index.tsx rename to app/src/local-resources/commands/hooks/useCommandTextString/index.ts index 3966a1bc7f4..0eb04ee588e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx +++ b/app/src/local-resources/commands/hooks/useCommandTextString/index.ts @@ -7,11 +7,11 @@ import type { RobotType, LabwareDefinition2, } from '@opentrons/shared-data' -import type { GetDirectTranslationCommandText } from './utils/getDirectTranslationCommandText' import type { TCProfileStepText, TCProfileCycleText, -} from './utils/getTCRunExtendedProfileCommandText' + GetDirectTranslationCommandText, +} from './utils' import type { CommandTextData } from '/app/local-resources/commands/types' export interface UseCommandTextStringParams { diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx new file mode 100644 index 00000000000..82b269bd581 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx @@ -0,0 +1,186 @@ +import { screen } from '@testing-library/react' +import { vi, describe, it, beforeEach } from 'vitest' +import { useTranslation } from 'react-i18next' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { + getLabwareDefinitionsFromCommands, + getLabwareName, + getLoadedLabware, + getLabwareDisplayLocation, +} from '/app/local-resources/labware' +import { getPipettingCommandText } from '../getPipettingCommandText' +import { getLabwareDefURI } from '@opentrons/shared-data' +import { getFinalLabwareLocation } from '../../getFinalLabwareLocation' +import { getWellRange } from '../../getWellRange' +import { getFinalMoveToAddressableAreaCmd } from '../../getFinalAddressableAreaCmd' +import { getAddressableAreaDisplayName } from '../../getAddressableAreaDisplayName' + +vi.mock('@opentrons/shared-data') +vi.mock('../../getFinalLabwareLocation') +vi.mock('../../getWellRange') +vi.mock('/app/local-resources/labware') +vi.mock('../../getFinalAddressableAreaCmd') +vi.mock('../../getAddressableAreaDisplayName') + +const baseCommandData = { + allRunDefs: {}, + robotType: 'OT-2', + commandTextData: { + commands: [], + labware: [], + modules: [], + pipettes: [{ id: 'pipette-1', pipetteName: 'p300_single' }], + }, +} as any + +function TestWrapper({ command }: { command: any }): JSX.Element { + const { t } = useTranslation('protocol_command_text') + const text = getPipettingCommandText({ + command, + ...baseCommandData, + t, + }) + + return
{text}
+} + +const render = (command: any) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('getPipettingCommandText', () => { + beforeEach(() => { + vi.mocked(getLabwareDefURI).mockImplementation((def: any) => def.uri) + vi.mocked(getFinalLabwareLocation).mockReturnValue('slot-1' as any) + vi.mocked(getWellRange).mockReturnValue('A1') + vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([ + { uri: 'tiprack-uri', parameters: { isTiprack: true } }, + { uri: 'plate-uri', parameters: { isTiprack: false } }, + ] as any) + vi.mocked(getLabwareName).mockReturnValue('Test Labware') + vi.mocked(getLoadedLabware).mockImplementation( + (labware, id) => + ({ + definitionUri: id === 'tiprack-id' ? 'tiprack-uri' : 'plate-uri', + } as any) + ) + vi.mocked(getLabwareDisplayLocation).mockReturnValue('Slot 1') + vi.mocked(getFinalMoveToAddressableAreaCmd).mockReturnValue({ + id: 'cmd-1', + commandType: 'moveToAddressableArea', + } as any) + vi.mocked(getAddressableAreaDisplayName).mockReturnValue('Fixed Trash') + }) + + it('should render aspirate command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'aspirate', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + }, + } + + render(command) + screen.getByText( + /Aspirating 100 µL from well A1 of Test Labware in Slot 1 at 150 µL\/sec/ + ) + }) + + it('should render dispense command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dispense', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + }, + } + + render(command) + screen.getByText( + /Dispensing 100 µL into well A1 of Test Labware in Slot 1 at 150 µL\/sec/ + ) + }) + + it('should render dispense with push out command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dispense', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + pushOut: 10, + }, + } + + render(command) + screen.getByText( + /Dispensing 100 µL into well A1 of Test Labware in Slot 1 at 150 µL\/sec and pushing out 10 µL/ + ) + }) + + it('should render pickup tip command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'pickUpTip', + params: { + labwareId: 'tiprack-id', + wellName: 'A1', + pipetteId: 'pipette-1', + }, + } + + render(command) + screen.getByText(/Picking up tip\(s\) from A1 of Test Labware in Slot 1/) + }) + + it('should render drop tip in tiprack command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTip', + params: { + labwareId: 'tiprack-id', + wellName: 'A1', + }, + } + + render(command) + screen.getByText(/Returning tip to A1 of Test Labware in Slot 1/) + }) + + it('should render drop tip in place command text correctly if there is an addressable area name', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTipInPlace', + params: {}, + } + + render(command) + screen.getByText('Dropping tip in Fixed Trash') + }) + + it('should render drop tip in place command text correctly if there is not an addressable area name', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTipInPlace', + params: {}, + } + + vi.mocked(getFinalMoveToAddressableAreaCmd).mockReturnValue(null) + + render(command) + screen.getByText('Dropping tip in place') + }) +}) diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts index b9e7107b569..926ed749609 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts @@ -5,7 +5,7 @@ import type { AbsorbanceReaderReadRunTimeCommand, RunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export type AbsorbanceCreateCommand = | AbsorbanceReaderOpenLidRunTimeCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts similarity index 83% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts index 3a1b7ce7e8a..f50de82c96e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts @@ -1,5 +1,5 @@ import type { CommentRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getCommentCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts index 1a4ee2e7c0e..fd9456b4cc3 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { ConfigureForVolumeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getConfigureForVolumeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts similarity index 95% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts index 04d476fadd1..8c9e12f3d5b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { ConfigureNozzleLayoutRunTimeCommand } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getConfigureNozzleLayoutCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts index da6d5a1d506..97d60249f7e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts @@ -1,5 +1,5 @@ import type { CustomRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getCustomCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts index 8bb24d99661..f421a163b36 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts @@ -1,5 +1,5 @@ import type { DeprecatedDelayRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getDelayCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts index fd586136e90..92e969f402b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts @@ -1,5 +1,5 @@ import type { RunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' const SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE: { [commandType in RunTimeCommand['commandType']]?: string diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts index 3710e7f0930..157cde89212 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts @@ -1,5 +1,5 @@ import type { HeaterShakerSetAndWaitForShakeSpeedRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getHSShakeSpeedCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts index 171667012fe..014d30318eb 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts @@ -2,14 +2,14 @@ import { getLabwareName, getLabwareDisplayLocation, } from '/app/local-resources/labware' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import type { LiquidProbeRunTimeCommand, RunTimeCommand, TryLiquidProbeRunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' type LiquidProbeRunTimeCommands = | LiquidProbeRunTimeCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts index d8ab8736e08..cba135218c8 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts @@ -5,8 +5,8 @@ import { getPipetteSpecsV2, } from '@opentrons/shared-data' -import { getPipetteNameOnMount } from './getPipetteNameOnMount' -import { getLiquidDisplayName } from './getLiquidDisplayName' +import { getPipetteNameOnMount } from '../getPipetteNameOnMount' +import { getLiquidDisplayName } from '../getLiquidDisplayName' import { getLabwareName } from '/app/local-resources/labware' import { @@ -15,7 +15,7 @@ import { } from '/app/local-resources/modules' import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' export const getLoadCommandText = ({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts similarity index 94% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts index 67fe3d52aaf..29e90946bb4 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts @@ -1,13 +1,13 @@ import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import { getLabwareName, getLabwareDisplayLocation, } from '/app/local-resources/labware' import type { MoveLabwareRunTimeCommand } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveLabwareCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts similarity index 86% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts index 7f3f8bf0aaa..d104e522fcd 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts @@ -1,5 +1,5 @@ import type { MoveRelativeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveRelativeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts similarity index 67% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts index 749ef30f451..5dd4adb4ca4 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts @@ -1,7 +1,7 @@ -import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' +import { getAddressableAreaDisplayName } from '../getAddressableAreaDisplayName' import type { MoveToAddressableAreaRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToAddressableAreaCommandText({ command, @@ -10,7 +10,7 @@ export function getMoveToAddressableAreaCommandText({ }: HandlesCommands): string { const addressableAreaDisplayName = commandTextData != null - ? getAddressableAreaDisplayName(commandTextData, command.id, t) + ? getAddressableAreaDisplayName(commandTextData.commands, command.id, t) : null return t('move_to_addressable_area', { diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts similarity index 69% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts index f7cc0f42e1f..29cd446a9ad 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts @@ -1,7 +1,7 @@ -import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' +import { getAddressableAreaDisplayName } from '../getAddressableAreaDisplayName' import type { MoveToAddressableAreaForDropTipRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToAddressableAreaForDropTipCommandText({ command, @@ -10,7 +10,7 @@ export function getMoveToAddressableAreaForDropTipCommandText({ }: HandlesCommands): string { const addressableAreaDisplayName = commandTextData != null - ? getAddressableAreaDisplayName(commandTextData, command.id, t) + ? getAddressableAreaDisplayName(commandTextData.commands, command.id, t) : null return t('move_to_addressable_area_drop_tip', { diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts similarity index 86% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts index a3dc5ace9fe..fde6e5aff22 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts @@ -1,5 +1,5 @@ import type { MoveToCoordinatesRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToCoordinatesCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts similarity index 85% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts index b66f5d78513..75904b7cb43 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts @@ -1,5 +1,5 @@ import type { MoveToSlotRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToSlotCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts index e3c8d6223be..50bdba0a52f 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts @@ -3,10 +3,10 @@ import { getLabwareDisplayLocation, } from '/app/local-resources/labware' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import type { MoveToWellRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToWellCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts similarity index 88% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts index 34ad5eae3a3..6ef1369691e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts @@ -1,7 +1,7 @@ import { getLabwareDefURI } from '@opentrons/shared-data' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' -import { getWellRange } from './getWellRange' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' +import { getWellRange } from '../getWellRange' import { getLabwareDefinitionsFromCommands, @@ -11,7 +11,9 @@ import { } from '/app/local-resources/labware' import type { PipetteName, RunTimeCommand } from '@opentrons/shared-data' -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' +import { getFinalMoveToAddressableAreaCmd } from '/app/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd' +import { getAddressableAreaDisplayName } from '/app/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName' export const getPipettingCommandText = ({ command, @@ -186,7 +188,14 @@ export const getPipettingCommandText = ({ }) } case 'dropTipInPlace': { - return t('drop_tip_in_place') + const cmd = getFinalMoveToAddressableAreaCmd(allPreviousCommands ?? []) + + if (cmd != null) { + const displayName = getAddressableAreaDisplayName([cmd], cmd?.id, t) + return t('dropping_tip_in_trash', { trash: displayName }) + } else { + return t('drop_tip_in_place') + } } case 'dispenseInPlace': { const { volume, flowRate } = command.params diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts index 13d32b6b7d6..f0d68c3fd4d 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { PrepareToAspirateRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getPrepareToAspirateCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts similarity index 89% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts index b731d3ec392..8fe4c18aa4b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts @@ -1,5 +1,5 @@ import type { RunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' type HandledCommands = Extract diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts index 4c4acde0b6f..2d09f07f28c 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts @@ -4,8 +4,8 @@ import type { TCProfileCycle, AtomicProfileStep, } from '@opentrons/shared-data/command' -import type { GetTCRunExtendedProfileCommandTextResult } from '..' -import type { HandlesCommands } from './types' +import type { GetTCRunExtendedProfileCommandTextResult } from '../..' +import type { HandlesCommands } from '../types' export interface TCProfileStepText { kind: 'step' diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts index cbc56b02635..a98ce9cfa4a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts @@ -1,7 +1,7 @@ import { formatDurationLabeled } from '/app/transformations/commands' import type { TCRunProfileRunTimeCommand } from '@opentrons/shared-data/command' -import type { GetTCRunProfileCommandTextResult } from '..' -import type { HandlesCommands } from './types' +import type { GetTCRunProfileCommandTextResult } from '../..' +import type { HandlesCommands } from '../types' export function getTCRunProfileCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts index ee60a76c289..1b5a03745c3 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts @@ -6,7 +6,7 @@ import type { HeaterShakerSetTargetTemperatureCreateCommand, RunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export type TemperatureCreateCommand = | TemperatureModuleSetTargetTemperatureCreateCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts similarity index 71% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts index 4f2346c7c01..17b69b84c6a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts @@ -1,4 +1,4 @@ -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' export function getUnknownCommandText({ command }: GetCommandText): string { return JSON.stringify(command) diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts index d3b3136be1f..18ccc55540a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts @@ -1,5 +1,5 @@ import type { WaitForDurationRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getWaitForDurationCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts index f1c7b7fcef6..a591504b244 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts @@ -1,5 +1,5 @@ import type { WaitForResumeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getWaitForResumeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts new file mode 100644 index 00000000000..c2926c880c6 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts @@ -0,0 +1,26 @@ +export * from './getLoadCommandText' +export * from './getTemperatureCommandText' +export * from './getTCRunProfileCommandText' +export * from './getTCRunExtendedProfileCommandText' +export * from './getHSShakeSpeedCommandText' +export * from './getMoveToSlotCommandText' +export * from './getMoveRelativeCommandText' +export * from './getMoveToCoordinatesCommandText' +export * from './getMoveToWellCommandText' +export * from './getMoveLabwareCommandText' +export * from './getConfigureForVolumeCommandText' +export * from './getConfigureNozzleLayoutCommandText' +export * from './getPrepareToAspirateCommandText' +export * from './getMoveToAddressableAreaCommandText' +export * from './getMoveToAddressableAreaForDropTipCommandText' +export * from './getDirectTranslationCommandText' +export * from './getWaitForDurationCommandText' +export * from './getWaitForResumeCommandText' +export * from './getDelayCommandText' +export * from './getCommentCommandText' +export * from './getCustomCommandText' +export * from './getUnknownCommandText' +export * from './getPipettingCommandText' +export * from './getLiquidProbeCommandText' +export * from './getRailLightsCommandText' +export * from './getAbsorbanceReaderCommandText' diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts index 20d7c6cca07..c3160de6223 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts @@ -1,17 +1,16 @@ import type { AddressableAreaName, MoveToAddressableAreaParams, + RunTimeCommand, } from '@opentrons/shared-data' import type { TFunction } from 'i18next' -import type { CommandTextData } from '/app/local-resources/commands' - export function getAddressableAreaDisplayName( - commandTextData: CommandTextData, + commands: RunTimeCommand[] | undefined, commandId: string, t: TFunction ): string { - const addressableAreaCommand = (commandTextData?.commands ?? []).find( + const addressableAreaCommand = (commands ?? []).find( command => command.id === commandId ) @@ -30,8 +29,11 @@ export function getAddressableAreaDisplayName( return t('trash_bin_in_slot', { slot_name: slotName }) } else if (addressableAreaName.includes('WasteChute')) { return t('waste_chute') - } else if (addressableAreaName === 'fixedTrash') return t('fixed_trash') - else return addressableAreaName + } else if (addressableAreaName === 'fixedTrash') { + return t('fixed_trash') + } else { + return addressableAreaName + } } const getMovableTrashSlot = ( diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts new file mode 100644 index 00000000000..3471073a8b9 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts @@ -0,0 +1,21 @@ +import { findLastAt } from '/app/local-resources/commands/hooks/useCommandTextString/utils/helpers' + +import type { RunTimeCommand } from '@opentrons/shared-data' +/** + * given a list of commands and a labwareId, calculate the resulting location + * of the corresponding labware after all given commands are executed + * @param commands list of commands to search within + * @returns The last command related to addressable areas. + */ +export function getFinalMoveToAddressableAreaCmd( + commands: RunTimeCommand[] +): RunTimeCommand | null { + const [cmd] = findLastAt( + commands, + (c: RunTimeCommand) => + c.commandType === 'moveToAddressableArea' || + c.commandType === 'moveToAddressableAreaForDropTip' + ) + + return cmd ?? null +} diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts index e674794a265..7e73770cbbd 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts @@ -1,3 +1,5 @@ +import { findLastAt } from './helpers' + import type { LabwareLocation, RunTimeCommand, @@ -5,22 +7,6 @@ import type { MoveLabwareRunTimeCommand, } from '@opentrons/shared-data' -const findLastAt = ( - arr: readonly T[], - pred: ((el: T) => boolean) | ((el: T) => el is U) -): [U, number] | [undefined, -1] => { - let arrayLoc = -1 - const lastEl = arr.findLast((el: T, idx: number): el is U => { - arrayLoc = idx - return pred(el) - }) - if (lastEl === undefined) { - return [undefined, -1] - } else { - return [lastEl, arrayLoc] - } -} - /** * given a list of commands and a labwareId, calculate the resulting location * of the corresponding labware after all given commands are executed diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts new file mode 100644 index 00000000000..5d28c41b82c --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts @@ -0,0 +1,15 @@ +export const findLastAt = ( + arr: readonly T[], + pred: ((el: T) => boolean) | ((el: T) => el is U) +): [U, number] | [undefined, -1] => { + let arrayLoc = -1 + const lastEl = arr.findLast((el: T, idx: number): el is U => { + arrayLoc = idx + return pred(el) + }) + if (lastEl === undefined) { + return [undefined, -1] + } else { + return [lastEl, arrayLoc] + } +} diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts index 76659ca1222..44f99055f08 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts @@ -1,26 +1,2 @@ -export { getLoadCommandText } from './getLoadCommandText' -export { getTemperatureCommandText } from './getTemperatureCommandText' -export { getTCRunProfileCommandText } from './getTCRunProfileCommandText' -export { getTCRunExtendedProfileCommandText } from './getTCRunExtendedProfileCommandText' -export { getHSShakeSpeedCommandText } from './getHSShakeSpeedCommandText' -export { getMoveToSlotCommandText } from './getMoveToSlotCommandText' -export { getMoveRelativeCommandText } from './getMoveRelativeCommandText' -export { getMoveToCoordinatesCommandText } from './getMoveToCoordinatesCommandText' -export { getMoveToWellCommandText } from './getMoveToWellCommandText' -export { getMoveLabwareCommandText } from './getMoveLabwareCommandText' -export { getConfigureForVolumeCommandText } from './getConfigureForVolumeCommandText' -export { getConfigureNozzleLayoutCommandText } from './getConfigureNozzleLayoutCommandText' -export { getPrepareToAspirateCommandText } from './getPrepareToAspirateCommandText' -export { getMoveToAddressableAreaCommandText } from './getMoveToAddressableAreaCommandText' -export { getMoveToAddressableAreaForDropTipCommandText } from './getMoveToAddressabelAreaForDropTipCommandText' -export { getDirectTranslationCommandText } from './getDirectTranslationCommandText' -export { getWaitForDurationCommandText } from './getWaitForDurationCommandText' -export { getWaitForResumeCommandText } from './getWaitForResumeCommandText' -export { getDelayCommandText } from './getDelayCommandText' -export { getCommentCommandText } from './getCommentCommandText' -export { getCustomCommandText } from './getCustomCommandText' -export { getUnknownCommandText } from './getUnknownCommandText' -export { getPipettingCommandText } from './getPipettingCommandText' -export { getLiquidProbeCommandText } from './getLiquidProbeCommandText' -export { getRailLightsCommandText } from './getRailLightsCommandText' -export { getAbsorbanceReaderCommandText } from './getAbsorbanceReaderCommandText' +export * from './commandText' +export * from './types' diff --git a/app/src/local-resources/labware/types.ts b/app/src/local-resources/labware/types.ts index da55c9d7004..3ab026b9603 100644 --- a/app/src/local-resources/labware/types.ts +++ b/app/src/local-resources/labware/types.ts @@ -21,6 +21,7 @@ export type LabwareFilter = | 'aluminumBlock' | 'customLabware' | 'adapter' + | 'lid' export type LabwareSort = 'alphabetical' | 'reverse' diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index 6999063be38..f2762b622d7 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -444,7 +444,7 @@ describe('CommandText', () => { />, { i18nInstance: i18n } ) - screen.getByText('Dropping tip in place') + screen.getByText('Dropping tip in D3') }) it('renders correct text for pickUpTip', () => { const command = mockCommandTextData.commands.find( diff --git a/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx b/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx index 9c6602023a8..83b588f4a6e 100644 --- a/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx +++ b/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx @@ -32,6 +32,8 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import tiprackAdapter from '/app/assets/images/labware/opentrons_flex_96_tiprack_adapter.png' +import tcLid from '/app/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png' +import deckRiser from '/app/assets/images/labware/opentrons_flex_deck_riser.png' import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' @@ -58,6 +60,13 @@ const LIST_ITEM_STYLE = css` justify-content: ${JUSTIFY_SPACE_BETWEEN}; ` +const ADAPTER_LOAD_NAMES_TO_SHOW_IMAGE: { [key: string]: string } = { + opentrons_flex_96_tiprack_adapter: tiprackAdapter, + opentrons_flex_deck_riser: deckRiser, +} +const LABWARE_LOAD_NAMES_TO_SHOW_IMAGE: { [key: string]: string } = { + opentrons_tough_pcr_auto_sealing_lid: tcLid, +} interface LabwareStackModalProps { labwareIdTop: string commands: RunTimeCommand[] | null @@ -87,6 +96,7 @@ export const LabwareStackModal = ( moduleModel, labwareName, labwareNickname, + labwareQuantity, } = getLocationInfoNames(labwareIdTop, commands) const topDefinition = getSlotLabwareDefinition(labwareIdTop, commands) @@ -106,7 +116,25 @@ export const LabwareStackModal = ( moduleModel != null ? getModuleDisplayName(moduleModel) : null ?? '' const isAdapterForTiprack = adapterDef?.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' - const tiprackAdapterImg = + + const labwareImg = + topDefinition.parameters.loadName in LABWARE_LOAD_NAMES_TO_SHOW_IMAGE ? ( + + ) : null + + const adapterImg = + adapterDef != null && + adapterDef.parameters.loadName in ADAPTER_LOAD_NAMES_TO_SHOW_IMAGE ? ( + + ) : null const moduleImg = moduleModel != null ? ( @@ -139,25 +167,33 @@ export const LabwareStackModal = ( 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname + } /> - - - + {labwareImg != null ? ( + {labwareImg} + ) : ( + + + + )} - {adapterDef != null ? ( <> + - {isAdapterForTiprack ? ( - {tiprackAdapterImg} + {adapterImg != null ? ( + {adapterImg} ) : ( )} - {moduleModel != null ? ( - - ) : null} ) : null} {moduleModel != null ? ( - - - {moduleImg} - + <> + + + + {moduleImg} + + ) : null} @@ -200,24 +236,35 @@ export const LabwareStackModal = ( <> - - - - + 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname + } + /> + {labwareImg != null ? ( + {labwareImg} + ) : ( + + + + )} - {adapterDef != null ? ( <> + - {isAdapterForTiprack ? ( - {tiprackAdapterImg} + {adapterImg != null ? ( + {adapterImg} ) : ( )} - {moduleModel != null ? ( - - ) : null} ) : null} {moduleModel != null ? ( - - - {moduleImg} - + <> + + + + {moduleImg} + + ) : null} @@ -253,31 +300,23 @@ interface LabwareStackLabelProps { } function LabwareStackLabel(props: LabwareStackLabelProps): JSX.Element { const { text, subText, isOnDevice = false } = props - return isOnDevice ? ( - - {text} - {subText != null ? ( - - {subText} - - ) : null} - - ) : ( + return ( - {text} + + {text} + {subText != null ? ( - + {subText} ) : null} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx index 6269be78e83..f31a3bcf28d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx @@ -26,7 +26,7 @@ import { } from '@opentrons/components' import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { - getLabwareDisplayName, + getTopLabwareInfo, getModuleDisplayName, getModuleType, HEATERSHAKER_MODULE_TYPE, @@ -37,6 +37,7 @@ import { THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' +import { getLocationInfoNames } from '/app/transformations/commands' import { ToggleButton } from '/app/atoms/buttons' import { Divider } from '/app/atoms/structure' import { SecureLabwareModal } from './SecureLabwareModal' @@ -47,14 +48,10 @@ import type { RunTimeCommand, ModuleType, LabwareDefinition2, - LoadModuleRunTimeCommand, LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' import type { ModuleRenderInfoForProtocol } from '/app/resources/runs' -import type { - LabwareSetupItem, - NestedLabwareInfo, -} from '/app/transformations/commands' +import type { LabwareSetupItem } from '/app/transformations/commands' import type { ModuleTypesThatRequireExtraAttention } from '../utils/getModuleTypesThatRequireExtraAttention' const LabwareRow = styled.div` @@ -73,7 +70,6 @@ interface LabwareListItemProps extends LabwareSetupItem { extraAttentionModules: ModuleTypesThatRequireExtraAttention[] isFlex: boolean commands: RunTimeCommand[] - nestedLabwareInfo: NestedLabwareInfo | null showLabwareSVG?: boolean } @@ -82,37 +78,48 @@ export function LabwareListItem( ): JSX.Element | null { const { attachedModuleInfo, - nickName, + nickName: bottomLabwareNickname, initialLocation, - definition, moduleModel, - moduleLocation, extraAttentionModules, isFlex, commands, - nestedLabwareInfo, showLabwareSVG, + labwareId: bottomLabwareId, } = props + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + bottomLabwareId ?? '', + loadLabwareCommands + ) + const { + slotName, + labwareName, + labwareNickname, + labwareQuantity, + adapterName: bottomLabwareName, + } = getLocationInfoNames(topLabwareId, commands) + + const isStacked = + labwareQuantity > 1 || + bottomLabwareId !== topLabwareId || + moduleModel != null + const { i18n, t } = useTranslation('protocol_setup') const [ secureLabwareModalType, setSecureLabwareModalType, ] = useState(null) - const labwareDisplayName = getLabwareDisplayName(definition) const { createLiveCommand } = useCreateLiveCommandMutation() const [isLatchLoading, setIsLatchLoading] = useState(false) const [isLatchClosed, setIsLatchClosed] = useState(false) - let slotInfo: string | null = null - - if (initialLocation !== 'offDeck' && 'slotName' in initialLocation) { - slotInfo = initialLocation.slotName - } else if ( - initialLocation !== 'offDeck' && - 'addressableAreaName' in initialLocation - ) { - slotInfo = initialLocation.addressableAreaName - } else if (initialLocation === 'offDeck') { + let slotInfo: string | null = slotName + if (initialLocation === 'offDeck') { slotInfo = i18n.format(t('off_deck'), 'upperCase') } @@ -126,50 +133,20 @@ export function LabwareListItem( | HeaterShakerOpenLatchCreateCommand | HeaterShakerCloseLatchCreateCommand - if (initialLocation !== 'offDeck' && 'labwareId' in initialLocation) { - const loadedAdapter = commands.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === initialLocation.labwareId - ) - const loadedAdapterLocation = loadedAdapter?.params.location - - if (loadedAdapterLocation != null && loadedAdapterLocation !== 'offDeck') { - if ('slotName' in loadedAdapterLocation) { - slotInfo = loadedAdapterLocation.slotName - } else if ('moduleId' in loadedAdapterLocation) { - const module = commands.find( - (command): command is LoadModuleRunTimeCommand => - command.commandType === 'loadModule' && - command.result?.moduleId === loadedAdapterLocation.moduleId - ) - if (module != null) { - slotInfo = module.params.location.slotName - moduleDisplayName = getModuleDisplayName(module.params.model) - } - } - } - } - if ( - initialLocation !== 'offDeck' && - 'moduleId' in initialLocation && - moduleLocation != null && - moduleModel != null - ) { - const moduleName = getModuleDisplayName(moduleModel) + if (moduleModel != null) { moduleType = getModuleType(moduleModel) + moduleDisplayName = getModuleDisplayName(moduleModel) + const moduleTypeNeedsAttention = extraAttentionModules.find( extraAttentionModType => extraAttentionModType === moduleType ) - let moduleSlotName = moduleLocation.slotName - if (moduleType === THERMOCYCLER_MODULE_TYPE) { - moduleSlotName = isFlex ? TC_MODULE_LOCATION_OT3 : TC_MODULE_LOCATION_OT2 - } - slotInfo = moduleSlotName - moduleDisplayName = moduleName + switch (moduleTypeNeedsAttention) { case MAGNETIC_MODULE_TYPE: case THERMOCYCLER_MODULE_TYPE: + if (moduleType === THERMOCYCLER_MODULE_TYPE) { + slotInfo = isFlex ? TC_MODULE_LOCATION_OT3 : TC_MODULE_LOCATION_OT2 + } if (moduleModel !== THERMOCYCLER_MODULE_V2) { secureLabwareInstructions = ( )} - {nestedLabwareInfo != null || moduleDisplayName != null ? ( - - ) : null} + {isStacked ? : null} - {nestedLabwareInfo != null && - nestedLabwareInfo?.sharedSlotId === slotInfo ? ( - <> - - + + {showLabwareSVG && topLabwareDefinition != null ? ( + + ) : null} + + + {labwareName} + + - - {nestedLabwareInfo.nestedLabwareDisplayName} - - - {nestedLabwareInfo.nestedLabwareNickName} - - + {labwareQuantity > 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname} + + + + {bottomLabwareName != null ? ( + <> + + + {bottomLabwareName} + + + {bottomLabwareNickname} + + ) : null} - - {showLabwareSVG ? ( - - ) : null} - - - {labwareDisplayName} - - - {nickName} - - - {moduleDisplayName != null ? ( <> @@ -371,9 +352,7 @@ export function LabwareListItem( marginTop="3px" > ))} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx index 647f1543677..b71c84da0f8 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx @@ -6,10 +6,7 @@ import { StyledText, COLORS, } from '@opentrons/components' -import { - getLabwareSetupItemGroups, - getNestedLabwareInfo, -} from '/app/transformations/commands' +import { getLabwareSetupItemGroups } from '/app/transformations/commands' import { LabwareListItem } from './LabwareListItem' import type { RunTimeCommand } from '@opentrons/shared-data' @@ -56,6 +53,7 @@ export function SetupLabwareList( {allItems.map((labwareItem, index) => { + // filtering out all labware that aren't on a module or the deck const labwareOnAdapter = allItems.find( item => labwareItem.initialLocation !== 'offDeck' && @@ -70,7 +68,6 @@ export function SetupLabwareList( extraAttentionModules={extraAttentionModules} {...labwareItem} isFlex={isFlex} - nestedLabwareInfo={getNestedLabwareInfo(labwareItem, commands)} /> ) })} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 0334496fd6b..c8bc460bbf4 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -12,7 +12,7 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' @@ -22,14 +22,17 @@ import { getProtocolModulesInfo, getLabwareRenderInfo, } from '/app/transformations/analysis' +import { LabwareStackModal } from '/app/molecules/LabwareStackModal' import { getStandardDeckViewLayerBlockList } from '/app/local-resources/deck_configuration' import { OffDeckLabwareList } from './OffDeckLabwareList' +import type { LabwareOnDeck } from '@opentrons/components' import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, + LoadLabwareRunTimeCommand, + RunTimeCommand, } from '@opentrons/shared-data' -import { LabwareStackModal } from '/app/molecules/LabwareStackModal' interface SetupLabwareMapProps { runId: string @@ -49,31 +52,25 @@ export function SetupLabwareMap({ if (protocolAnalysis == null) return null - const commands = protocolAnalysis.commands + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckDef = getDeckDefFromRobotType(robotType) const protocolModulesInfo = getProtocolModulesInfo(protocolAnalysis, deckDef) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - commands - ) - const modulesOnDeck = protocolModulesInfo.map(module => { - const labwareInAdapterInMod = - module.nestedLabwareId != null - ? initialLoadedLabwareByAdapter[module.nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? module.nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? module.nestedLabwareId - const topLabwareDisplayName = - labwareInAdapterInMod?.params.displayName ?? - module.nestedLabwareDisplayName + const isLabwareStacked = + module.nestedLabwareId != null && module.nestedLabwareDef != null + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(module.nestedLabwareId ?? '', loadLabwareCommands) return { moduleModel: module.moduleDef.model, @@ -84,15 +81,9 @@ export function SetupLabwareMap({ : {}, nestedLabwareDef: topLabwareDefinition, - highlightLabware: - topLabwareDefinition != null && - topLabwareId != null && - hoverLabwareId === topLabwareId, - highlightShadowLabware: - topLabwareDefinition != null && - topLabwareId != null && - hoverLabwareId === topLabwareId, - stacked: topLabwareDefinition != null && topLabwareId != null, + highlightLabware: hoverLabwareId === topLabwareId, + highlightShadowLabware: hoverLabwareId === topLabwareId, + stacked: isLabwareStacked, moduleChildren: ( // open modal ) : null} @@ -130,59 +121,59 @@ export function SetupLabwareMap({ const labwareRenderInfo = getLabwareRenderInfo(protocolAnalysis, deckDef) - const labwareOnDeck = map( + const labwareOnDeck: Array = map( labwareRenderInfo, - ({ labwareDef, displayName, slotName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId - const topLabwareDisplayName = - labwareInAdapter?.params.displayName ?? displayName - const isLabwareInStack = - topLabwareDefinition != null && - topLabwareId != null && - labwareInAdapter != null - - return { - labwareLocation: { slotName }, - definition: topLabwareDefinition, + ({ slotName }, labwareId) => { + const { topLabwareId, + topLabwareDefinition, topLabwareDisplayName, - highlight: isLabwareInStack && hoverLabwareId === topLabwareId, - highlightShadow: isLabwareInStack && hoverLabwareId === topLabwareId, - labwareChildren: ( - { - if (isLabwareInStack) { - setLabwareStackDetailsLabwareId(topLabwareId) - } - }} - onMouseEnter={() => { - if (topLabwareDefinition != null && topLabwareId != null) { - setHoverLabwareId(() => topLabwareId) - } - }} - onMouseLeave={() => { - setHoverLabwareId(null) - }} - > - - - ), - stacked: isLabwareInStack, - } + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const isLabwareInStack = labwareId !== topLabwareId + return topLabwareDefinition != null + ? { + labwareLocation: { slotName }, + definition: topLabwareDefinition, + highlight: isLabwareInStack && hoverLabwareId === topLabwareId, + highlightShadow: + isLabwareInStack && hoverLabwareId === topLabwareId, + stacked: isLabwareInStack, + labwareChildren: ( + { + if (isLabwareInStack) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + onMouseEnter={() => { + if (topLabwareDefinition != null && topLabwareId != null) { + setHoverLabwareId(() => topLabwareId) + } + }} + onMouseLeave={() => { + setHoverLabwareId(null) + }} + > + {topLabwareDefinition != null ? ( + + ) : null} + + ), + } + : null } ) + const labwareOnDeckFiltered: LabwareOnDeck[] = labwareOnDeck.filter( + (labware): labware is LabwareOnDeck => labware != null + ) + return ( @@ -191,7 +182,7 @@ export function SetupLabwareMap({ deckConfig={deckConfig} deckLayerBlocklist={getStandardDeckViewLayerBlockList(robotType)} robotType={robotType} - labwareOnDeck={labwareOnDeck} + labwareOnDeck={labwareOnDeckFiltered} modulesOnDeck={modulesOnDeck} /> diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx index 904395f7c98..50afda3d92f 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx @@ -3,7 +3,10 @@ import { fireEvent, screen } from '@testing-library/react' import { describe, it, beforeEach, vi, expect } from 'vitest' import { MemoryRouter } from 'react-router-dom' -import { opentrons96PcrAdapterV1 } from '@opentrons/shared-data' +import { + opentrons96PcrAdapterV1, + getTopLabwareInfo, +} from '@opentrons/shared-data' import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '/app/__testing-utils__' @@ -14,6 +17,7 @@ import { mockTemperatureModule, mockThermocycler, } from '/app/redux/modules/__fixtures__' +import { getLocationInfoNames } from '/app/transformations/commands' import { mockLabwareDef } from '/app/organisms/LabwarePositionCheck/__fixtures__/mockLabwareDef' import { SecureLabwareModal } from '../SecureLabwareModal' import { LabwareListItem } from '../LabwareListItem' @@ -28,7 +32,15 @@ import type { AttachedModule } from '/app/redux/modules/types' import type { ModuleRenderInfoForProtocol } from '/app/resources/runs' vi.mock('../SecureLabwareModal') +vi.mock('/app/transformations/commands') vi.mock('@opentrons/react-api-client') +vi.mock('@opentrons/shared-data', async importOriginal => { + const actualSharedData = await importOriginal() + return { + ...actualSharedData, + getTopLabwareInfo: vi.fn(), + } +}) const mockAdapterDef = opentrons96PcrAdapterV1 as LabwareDefinition2 const mockAdapterId = 'mockAdapterId' @@ -87,12 +99,23 @@ describe('LabwareListItem', () => { vi.mocked(useCreateLiveCommandMutation).mockReturnValue({ createLiveCommand: mockCreateLiveCommand, } as any) + vi.mocked(getLocationInfoNames).mockReturnValue({ + slotName: '7', + labwareName: 'Mock Labware Definition', + labwareNickname: 'nickName', + labwareQuantity: 1, + }) + vi.mocked(getTopLabwareInfo).mockReturnValue({ + topLabwareId: '1', + topLabwareDefinition: mockLabwareDef, + }) }) it('renders the correct info for a thermocycler (OT2), clicking on secure labware instructions opens the modal', () => { render({ commands: [], nickName: mockNickName, + labwareId: '7', definition: mockLabwareDef, initialLocation: { moduleId: mockModuleId }, moduleModel: 'thermocyclerModuleV1' as ModuleModel, @@ -107,7 +130,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByText('nickName') @@ -137,7 +159,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: true, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByText('A1+B1') @@ -168,7 +189,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -203,7 +223,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -245,7 +264,7 @@ describe('LabwareListItem', () => { nickName: mockNickName, definition: mockLabwareDef, initialLocation: { labwareId: mockAdapterId }, - moduleModel: 'temperatureModuleV1' as ModuleModel, + moduleModel: 'temperatureModuleV2' as ModuleModel, moduleLocation: mockModuleSlot, extraAttentionModules: [], attachedModuleInfo: { @@ -262,18 +281,11 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: { - nestedLabwareDisplayName: 'mock nested display name', - sharedSlotId: '7', - nestedLabwareNickName: 'nestedLabwareNickName', - nestedLabwareDefinition: mockLabwareDef, - }, }) screen.getByText('Mock Labware Definition') screen.getAllByText('7') screen.getByText('Temperature Module GEN2') - screen.getByText('mock nested display name') - screen.getByText('nestedLabwareNickName') + screen.getByText('Mock Labware Definition') screen.getByText('nickName') }) @@ -293,10 +305,17 @@ describe('LabwareListItem', () => { z: 1.2, }, } as any + vi.mocked(getLocationInfoNames).mockReturnValue({ + slotName: 'A2', + labwareName: 'Mock Labware Name', + labwareNickname: 'labware nick name', + labwareQuantity: 1, + adapterName: 'mock adapter name', + }) render({ commands: [mockAdapterLoadCommand], - nickName: mockNickName, + nickName: 'mock adapter nick name', definition: mockLabwareDef, initialLocation: { labwareId: mockAdapterId }, moduleModel: null, @@ -304,18 +323,13 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isFlex: false, - nestedLabwareInfo: { - nestedLabwareDisplayName: 'mock nested display name', - sharedSlotId: 'A2', - nestedLabwareNickName: 'nestedLabwareNickName', - nestedLabwareDefinition: mockLabwareDef, - }, + labwareId: '5', }) - screen.getByText('Mock Labware Definition') + screen.getByText('Mock Labware Name') + screen.getByText('labware nick name') screen.getByText('A2') - screen.getByText('mock nested display name') - screen.getByText('nestedLabwareNickName') - screen.getByText('nickName') + screen.getByText('mock adapter name') + screen.getByText('mock adapter nick name') }) it('renders the correct info for a labware on top of a heater shaker', () => { @@ -341,7 +355,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -363,6 +376,7 @@ describe('LabwareListItem', () => { }) it('renders the correct info for an off deck labware', () => { + vi.mocked(getTopLabwareInfo) render({ nickName: null, definition: mockLabwareDef, @@ -373,7 +387,6 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_OFF DECK') diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 5338a9ce055..1b556692f8d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -14,7 +14,7 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, parseLabwareInfoByLiquidId, parseLiquidsInLoadOrder, THERMOCYCLER_MODULE_V1, @@ -32,6 +32,8 @@ import { import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' interface SetupLiquidsMapProps { @@ -50,13 +52,16 @@ export function SetupLiquidsMap( if (protocolAnalysis == null) return null + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + const liquids = parseLiquidsInLoadOrder( protocolAnalysis.liquids != null ? protocolAnalysis.liquids : [], protocolAnalysis.commands ?? [] ) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - protocolAnalysis.commands ?? [] - ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckDef = getDeckDefFromRobotType(robotType) const labwareRenderInfo = getLabwareRenderInfo(protocolAnalysis, deckDef) @@ -69,19 +74,11 @@ export function SetupLiquidsMap( const protocolModulesInfo = getProtocolModulesInfo(protocolAnalysis, deckDef) const modulesOnDeck = protocolModulesInfo.map(module => { - const labwareInAdapterInMod = - module.nestedLabwareId != null - ? initialLoadedLabwareByAdapter[module.nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? module.nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? module.nestedLabwareId - const topLabwareDisplayName = - labwareInAdapterInMod?.params.displayName ?? - module.nestedLabwareDisplayName + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(module.nestedLabwareId ?? '', loadLabwareCommands) const nestedLabwareWellFill = getWellFillFromLabwareId( topLabwareId ?? '', liquids, @@ -120,7 +117,7 @@ export function SetupLiquidsMap( hover={topLabwareId === hoverLabwareId && labwareHasLiquid} labwareHasLiquid={labwareHasLiquid} labwareId={topLabwareId} - displayName={topLabwareDisplayName} + displayName={topLabwareDisplayName ?? null} runId={runId} /> @@ -140,59 +137,52 @@ export function SetupLiquidsMap( labwareOnDeck={[]} modulesOnDeck={modulesOnDeck} > - {map( - labwareRenderInfo, - ({ x, y, labwareDef, displayName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = - labwareInAdapter?.result?.labwareId ?? labwareId - const topLabwareDisplayName = - labwareInAdapter?.params.displayName ?? displayName - const wellFill = getWellFillFromLabwareId( - topLabwareId ?? '', - liquids, - labwareByLiquidId - ) - const labwareHasLiquid = !isEmpty(wellFill) - return ( - - { - setHoverLabwareId(topLabwareId) - }} - onMouseLeave={() => { - setHoverLabwareId('') - }} - onClick={() => { - if (labwareHasLiquid) { - setLiquidDetailsLabwareId(topLabwareId) - } - }} - cursor={labwareHasLiquid ? 'pointer' : ''} - > - - - - - ) - } - )} + {map(labwareRenderInfo, ({ x, y }, labwareId) => { + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const wellFill = getWellFillFromLabwareId( + topLabwareId ?? '', + liquids, + labwareByLiquidId + ) + const labwareHasLiquid = !isEmpty(wellFill) + return topLabwareDefinition != null ? ( + + { + setHoverLabwareId(topLabwareId) + }} + onMouseLeave={() => { + setHoverLabwareId('') + }} + onClick={() => { + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } + }} + cursor={labwareHasLiquid ? 'pointer' : ''} + > + + + + + ) : null + })} {liquidDetailsLabwareId != null && ( { vi.mocked(getLocationInfoNames).mockReturnValue({ labwareName: 'mock labware name', slotName: '4', + labwareQuantity: 1, }) mockTrackEvent = vi.fn() vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx index 2cb4ced0207..e172b6ffb11 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx @@ -22,6 +22,7 @@ import { } from '@opentrons/components' import { ABSORBANCE_READER_TYPE, + ABSORBANCE_READER_V1, FLEX_ROBOT_TYPE, getCutoutIdForSlotName, getDeckDefFromRobotType, @@ -160,7 +161,6 @@ export function ModulesListItem({ displayName, slotName, attachedModuleMatch, - heaterShakerModuleFromProtocol, isFlex, calibrationStatus, conflictedFixture, @@ -172,9 +172,9 @@ export function ModulesListItem({ attachedModuleMatch != null ? t('module_connected') : t('module_not_connected') - const [showModuleSetupModal, setShowModuleSetupModal] = useState( - false - ) + const [showModuleSetupModal, setShowModuleSetupModal] = useState< + string | null + >(null) const [ showLocationConflictModal, setShowLocationConflictModal, @@ -204,7 +204,10 @@ export function ModulesListItem({ }) let subText: JSX.Element | null = null - if (moduleModel === HEATERSHAKER_MODULE_V1) { + if ( + moduleModel === HEATERSHAKER_MODULE_V1 || + moduleModel === ABSORBANCE_READER_V1 + ) { subText = ( { - setShowModuleSetupModal(true) + setShowModuleSetupModal(displayName) }} > @@ -328,14 +331,13 @@ export function ModulesListItem({ padding={SPACING.spacing16} backgroundColor={COLORS.white} > - {showModuleSetupModal && heaterShakerModuleFromProtocol != null ? ( + {showModuleSetupModal != null ? ( { - setShowModuleSetupModal(false) + setShowModuleSetupModal(null) }} - moduleDisplayName={ - heaterShakerModuleFromProtocol.moduleDef.displayName - } + moduleDisplayName={showModuleSetupModal} + isAbsorbanceReader={moduleModel === ABSORBANCE_READER_V1} /> ) : null} { id: 'heatershaker_id', model: 'heaterShakerModuleV1', moduleType: 'heaterShakerModuleType', + displayName: 'mockHeaterShakerName', serialNumber: 'jkl123', hardwareRevision: 'heatershaker_v4.0', firmwareVersion: 'v2.0.0', diff --git a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx index 3b862a94a9d..07fe453c932 100644 --- a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx @@ -42,7 +42,7 @@ export function EstopMissingModal({ ) : ( <> - {isDismissedModal === false ? ( + {!isDismissedModal ? ( - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} @@ -121,7 +121,7 @@ function DesktopModal({ {t('connect_the_estop_to_continue')} - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} diff --git a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx index 8dc996c3374..7c78de6b8e2 100644 --- a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx @@ -24,6 +24,7 @@ import { import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-client' +import { usePlacePlateReaderLid } from '/app/resources/modules' import { getTopPortalEl } from '/app/App/portal' import { SmallButton } from '/app/atoms/buttons' import { OddModal } from '/app/molecules/OddModal' @@ -40,19 +41,15 @@ import type { ModalProps } from '@opentrons/components' interface EstopPressedModalProps { isEngaged: boolean closeModal: () => void - isDismissedModal?: boolean - setIsDismissedModal?: (isDismissedModal: boolean) => void - isWaitingForLogicalDisengage: boolean - setShouldSeeLogicalDisengage: () => void + isWaitingForResumeOperation: boolean + setIsWaitingForResumeOperation: () => void } export function EstopPressedModal({ isEngaged, closeModal, - isDismissedModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const isOnDevice = useSelector(getIsOnDevice) return createPortal( @@ -60,20 +57,17 @@ export function EstopPressedModal({ ) : ( <> - {isDismissedModal === false ? ( - - ) : null} + ), getTopPortalEl() @@ -83,12 +77,19 @@ export function EstopPressedModal({ function TouchscreenModal({ isEngaged, closeModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation(['device_settings', 'branded']) const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() + + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalHeader: OddModalHeaderBaseProps = { title: t('estop_pressed'), iconName: 'ot-alert', @@ -100,9 +101,12 @@ function TouchscreenModal({ } const handleClick = (): void => { setIsResuming(true) + setIsWaitingForResumeOperation() acknowledgeEstopDisengage(null) - setShouldSeeLogicalDisengage() - closeModal() + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -131,15 +135,13 @@ function TouchscreenModal({ data-testid="Estop_pressed_button" width="100%" iconName={ - isResuming || isWaitingForLogicalDisengage - ? 'ot-spinner' - : undefined + isResuming || isWaitingForResumeOperation ? 'ot-spinner' : undefined } iconPlacement={ - isResuming || isWaitingForLogicalDisengage ? 'startIcon' : undefined + isResuming || isWaitingForResumeOperation ? 'startIcon' : undefined } buttonText={t('resume_robot_operations')} - disabled={isEngaged || isResuming || isWaitingForLogicalDisengage} + disabled={isEngaged || isResuming || isWaitingForResumeOperation} onClick={handleClick} /> @@ -150,25 +152,23 @@ function TouchscreenModal({ function DesktopModal({ isEngaged, closeModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation('device_settings') const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() - - const handleCloseModal = (): void => { - if (setIsDismissedModal != null) { - setIsDismissedModal(true) - } - closeModal() - } + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalProps: ModalProps = { type: 'error', title: t('estop_pressed'), - onClose: handleCloseModal, + onClose: closeModal, closeOnOutsideClick: false, childrenPadding: SPACING.spacing24, width: '47rem', @@ -177,19 +177,12 @@ function DesktopModal({ const handleClick: React.MouseEventHandler = (e): void => { e.preventDefault() setIsResuming(true) - acknowledgeEstopDisengage( - {}, - { - onSuccess: () => { - setShouldSeeLogicalDisengage() - closeModal() - }, - onError: (error: any) => { - setIsResuming(false) - console.error(error) - }, - } - ) + setIsWaitingForResumeOperation() + acknowledgeEstopDisengage(null) + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -204,14 +197,14 @@ function DesktopModal({ - {isResuming || isWaitingForLogicalDisengage ? ( + {isResuming || isWaitingForResumeOperation ? ( ) : null} {t('resume_robot_operations')} diff --git a/app/src/organisms/EmergencyStop/EstopTakeover.tsx b/app/src/organisms/EmergencyStop/EstopTakeover.tsx index 5967edae75a..cbd9ba1a310 100644 --- a/app/src/organisms/EmergencyStop/EstopTakeover.tsx +++ b/app/src/organisms/EmergencyStop/EstopTakeover.tsx @@ -1,18 +1,13 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useEstopQuery } from '@opentrons/react-api-client' import { EstopPressedModal } from './EstopPressedModal' import { EstopMissingModal } from './EstopMissingModal' -import { useEstopContext } from './hooks' import { useIsUnboxingFlowOngoing } from '/app/redux-resources/config' import { getLocalRobot } from '/app/redux/discovery' -import { - PHYSICALLY_ENGAGED, - LOGICALLY_ENGAGED, - NOT_PRESENT, - DISENGAGED, -} from './constants' +import { PHYSICALLY_ENGAGED, NOT_PRESENT, DISENGAGED } from './constants' +import type { EstopState } from '@opentrons/api-client' const ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS = 10000 const ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS = 1000 @@ -22,71 +17,62 @@ interface EstopTakeoverProps { } export function EstopTakeover({ robotName }: EstopTakeoverProps): JSX.Element { - const [estopEngaged, setEstopEngaged] = useState(false) + const [isDismissedModal, setIsDismissedModal] = useState(false) const [ - isWaitingForLogicalDisengage, - setIsWaitingForLogicalDisengage, + isWaitingForResumeOperation, + setIsWatingForResumeOperation, ] = useState(false) + + const [estopState, setEstopState] = useState() + const [showEmergencyStopModal, setShowEmergencyStopModal] = useState( + false + ) + + // TODO: (ba, 2024-10-24): Use notifications instead of polling const { data: estopStatus } = useEstopQuery({ - refetchInterval: estopEngaged + refetchInterval: showEmergencyStopModal ? ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS : ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS, - onSuccess: response => { - setEstopEngaged( - [PHYSICALLY_ENGAGED || LOGICALLY_ENGAGED].includes( - response?.data.status - ) - ) - setIsWaitingForLogicalDisengage(false) - }, }) + useEffect(() => { + if (estopStatus) { + setEstopState(estopStatus.data.status) + setShowEmergencyStopModal( + estopStatus.data.status !== DISENGAGED || isWaitingForResumeOperation + ) + } + }, [estopStatus]) - const { - isEmergencyStopModalDismissed, - setIsEmergencyStopModalDismissed, - } = useEstopContext() const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() const closeModal = (): void => { - if (estopStatus?.data.status === DISENGAGED) { - setIsEmergencyStopModalDismissed(false) - } + setIsWatingForResumeOperation(false) } const localRobot = useSelector(getLocalRobot) const localRobotName = localRobot?.name ?? 'no name' const TargetEstopModal = (): JSX.Element | null => { - switch (estopStatus?.data.status) { - case PHYSICALLY_ENGAGED: - case LOGICALLY_ENGAGED: - return ( - { - setIsWaitingForLogicalDisengage(true) - }} - /> - ) - case NOT_PRESENT: - return ( - - ) - default: - return null - } + return estopState === NOT_PRESENT ? ( + + ) : estopState !== DISENGAGED || isWaitingForResumeOperation ? ( + { + setIsWatingForResumeOperation(true) + }} + /> + ) : null } return ( <> - {estopStatus?.data.status !== DISENGAGED && !isUnboxingFlowOngoing ? ( + {showEmergencyStopModal && !isUnboxingFlowOngoing ? ( ) : null} diff --git a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx index 124ea72b3ed..067211c4c06 100644 --- a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx +++ b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx @@ -8,9 +8,11 @@ import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-clien import { i18n } from '/app/i18n' import { getIsOnDevice } from '/app/redux/config' import { EstopPressedModal } from '../EstopPressedModal' +import { usePlacePlateReaderLid } from '/app/resources/modules' vi.mock('@opentrons/react-api-client') vi.mock('/app/redux/config') +vi.mock('/app/resources/modules') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -25,13 +27,19 @@ describe('EstopPressedModal - Touchscreen', () => { props = { isEngaged: true, closeModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(true) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { @@ -59,6 +67,20 @@ describe('EstopPressedModal - Touchscreen', () => { render(props) fireEvent.click(screen.getByText('Resume robot operations')) expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() + }) + + it('should call a mock function to place the labware to a slot', () => { + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: true, + isExecuting: true, + }) + + render(props) + fireEvent.click(screen.getByText('Resume robot operations')) + expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() }) }) @@ -69,15 +91,19 @@ describe('EstopPressedModal - Desktop', () => { props = { isEngaged: true, closeModal: vi.fn(), - isDismissedModal: false, - setIsDismissedModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(false) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { render(props) @@ -99,10 +125,18 @@ describe('EstopPressedModal - Desktop', () => { ).not.toBeDisabled() }) + it('should resume robot operation button is disabled when waiting for labware plate to finish', () => { + props.isEngaged = false + props.isWaitingForResumeOperation = true + render(props) + expect( + screen.getByRole('button', { name: 'Resume robot operations' }) + ).toBeDisabled() + }) + it('should call a mock function when clicking close icon', () => { render(props) fireEvent.click(screen.getByTestId('ModalHeader_icon_close_E-stop pressed')) - expect(props.setIsDismissedModal).toHaveBeenCalled() expect(props.closeModal).toHaveBeenCalled() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index b0716af5c8a..a24afb09b29 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -7,6 +7,7 @@ import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, useRelevantFailedLwLocations, + useInitialSelectedLocationsFrom, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' @@ -241,3 +242,22 @@ describe('useRelevantFailedLwLocations', () => { expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' }) }) }) + +describe('useInitialSelectedLocationsFrom', () => { + it('updates result if the relevant command changes', () => { + const cmd = { commandType: 'pickUpTip', params: { wellName: 'A1' } } as any + const cmd2 = { commandType: 'pickUpTip', params: { wellName: 'A2' } } as any + + const { result, rerender } = renderHook((cmd: any) => + useInitialSelectedLocationsFrom(cmd) + ) + + rerender(cmd) + + expect(result.current).toStrictEqual({ A1: null }) + + rerender(cmd2) + + expect(result.current).toStrictEqual({ A2: null }) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index a553bdcb4ee..4079e8a8f1e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -5,6 +5,7 @@ import { useResumeRunFromRecoveryMutation, useStopRunMutation, useUpdateErrorRecoveryPolicy, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '@opentrons/react-api-client' import { useChainRunCommands } from '/app/resources/runs' @@ -14,13 +15,16 @@ import { RELEASE_GRIPPER_JAW, buildPickUpTips, buildIgnorePolicyRules, + isAssumeFalsePositiveResumeKind, UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, HOME_GRIPPER_Z, } from '../useRecoveryCommands' -import { RECOVERY_MAP } from '../../constants' +import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' +import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' vi.mock('@opentrons/react-api-client') vi.mock('/app/resources/runs') +vi.mock('/app/organisms/ErrorRecoveryFlows/utils') describe('useRecoveryCommands', () => { const mockFailedCommand = { @@ -41,6 +45,9 @@ describe('useRecoveryCommands', () => { const mockResumeRunFromRecovery = vi.fn(() => Promise.resolve(mockMakeSuccessToast()) ) + const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn(() => + Promise.resolve(mockMakeSuccessToast()) + ) const mockStopRun = vi.fn() const mockChainRunCommands = vi.fn().mockResolvedValue([]) const mockReportActionSelectedResult = vi.fn() @@ -73,6 +80,11 @@ describe('useRecoveryCommands', () => { vi.mocked(useUpdateErrorRecoveryPolicy).mockReturnValue({ mutateAsync: mockUpdateErrorRecoveryPolicy, } as any) + vi.mocked( + useResumeRunFromRecoveryAssumingFalsePositiveMutation + ).mockReturnValue({ + mutateAsync: mockResumeRunFromRecoveryAssumingFalsePositive, + } as any) }) it('should call chainRunRecoveryCommands with continuePastCommandFailure set to false', async () => { @@ -317,7 +329,8 @@ describe('useRecoveryCommands', () => { const expectedPolicyRules = buildIgnorePolicyRules( 'aspirateInPlace', - 'mockErrorType' + 'mockErrorType', + 'ignoreAndContinue' ) expect(mockUpdateErrorRecoveryPolicy).toHaveBeenCalledWith( @@ -354,4 +367,54 @@ describe('useRecoveryCommands', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.ROUTE ) }) + + describe('skipFailedCommand with false positive handling', () => { + it('should call resumeRunFromRecoveryAssumingFalsePositive for tip-related errors', async () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_NOT_DETECTED) + + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.skipFailedCommand() + }) + + expect( + mockResumeRunFromRecoveryAssumingFalsePositive + ).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() + }) + + it('should call regular resumeRunFromRecovery for non-tip-related errors', async () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.GRIPPER_ERROR) + + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.skipFailedCommand() + }) + + expect(mockResumeRunFromRecovery).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() + }) + }) +}) + +describe('isAssumeFalsePositiveResumeKind', () => { + it(`should return true for ${ERROR_KINDS.TIP_NOT_DETECTED} error kind`, () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_NOT_DETECTED) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(true) + }) + + it(`should return true for ${ERROR_KINDS.TIP_DROP_FAILED} error kind`, () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_DROP_FAILED) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(true) + }) + + it('should return false for other error kinds', () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.GRIPPER_ERROR) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(false) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index d108bfb7d0a..9ce04df1bdf 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -214,8 +214,9 @@ function useTipSelectionUtils( const initialLocs = useInitialSelectedLocationsFrom( recentRelevantFailedLabwareCmd ) - // Set the initial locs when they first become available. - if (selectedLocs == null && initialLocs != null) { + + // Set the initial locs when they first become available or update. + if (selectedLocs !== initialLocs) { setSelectedLocs(initialLocs) } @@ -253,17 +254,20 @@ function useTipSelectionUtils( } // Set the initial well selection to be the last pickup tip location for the pipette used in the failed command. -function useInitialSelectedLocationsFrom( +export function useInitialSelectedLocationsFrom( recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware ): WellGroup | null { const [initialWells, setInitialWells] = useState(null) // Note that while other commands may have a wellName associated with them, // we are only interested in wells for the purposes of tip picking up. + // Support state updates if the underlying data changes, since this data is lazily loaded and may change shortly + // after Error Recovery launches. if ( recentRelevantFailedLabwareCmd != null && recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' && - initialWells == null + (initialWells == null || + !(recentRelevantFailedLabwareCmd.params.wellName in initialWells)) ) { setInitialWells({ [recentRelevantFailedLabwareCmd.params.wellName]: null }) } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 69101d92fe9..7614dec4be3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -5,10 +5,12 @@ import { useResumeRunFromRecoveryMutation, useStopRunMutation, useUpdateErrorRecoveryPolicy, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '@opentrons/react-api-client' import { useChainRunCommands } from '/app/resources/runs' -import { RECOVERY_MAP } from '../constants' +import { ERROR_KINDS, RECOVERY_MAP } from '../constants' +import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' import type { CreateCommand, @@ -23,7 +25,9 @@ import type { } from '@opentrons/shared-data' import type { CommandData, + IfMatchType, RecoveryPolicyRulesParams, + RunAction, } from '@opentrons/api-client' import type { WellGroup } from '@opentrons/components' import type { FailedCommand, RecoveryRoute, RouteStep } from '../types' @@ -89,6 +93,9 @@ export function useRecoveryCommands({ const { mutateAsync: resumeRunFromRecovery, } = useResumeRunFromRecoveryMutation() + const { + mutateAsync: resumeRunFromRecoveryAssumingFalsePositive, + } = useResumeRunFromRecoveryAssumingFalsePositiveMutation() const { stopRun } = useStopRunMutation() const { mutateAsync: updateErrorRecoveryPolicy, @@ -198,9 +205,16 @@ export function useRecoveryCommands({ const handleIgnoringErrorKind = useCallback((): Promise => { if (ignoreErrors) { if (failedCommandByRunRecord?.error != null) { + const ifMatch: IfMatchType = isAssumeFalsePositiveResumeKind( + failedCommandByRunRecord + ) + ? 'assumeFalsePositiveAndContinue' + : 'ignoreAndContinue' + const ignorePolicyRules = buildIgnorePolicyRules( failedCommandByRunRecord.commandType, - failedCommandByRunRecord.error.errorType + failedCommandByRunRecord.error.errorType, + ifMatch ) return updateErrorRecoveryPolicy(ignorePolicyRules) @@ -247,9 +261,17 @@ export function useRecoveryCommands({ stopRun(runId) }, [runId]) + const handleResumeAction = (): Promise => { + if (isAssumeFalsePositiveResumeKind(failedCommandByRunRecord)) { + return resumeRunFromRecoveryAssumingFalsePositive(runId) + } else { + return resumeRunFromRecovery(runId) + } + } + const skipFailedCommand = useCallback((): void => { void handleIgnoringErrorKind().then(() => - resumeRunFromRecovery(runId).then(() => { + handleResumeAction().then(() => { analytics.reportActionSelectedResult( selectedRecoveryOption, 'succeeded' @@ -303,6 +325,20 @@ export function useRecoveryCommands({ } } +export function isAssumeFalsePositiveResumeKind( + failedCommandByRunRecord: UseRecoveryCommandsParams['failedCommandByRunRecord'] +): boolean { + const errorKind = getErrorKind(failedCommandByRunRecord) + + switch (errorKind) { + case ERROR_KINDS.TIP_NOT_DETECTED: + case ERROR_KINDS.TIP_DROP_FAILED: + return true + default: + return false + } +} + export const HOME_PIPETTE_Z_AXES: CreateCommand = { commandType: 'home', params: { axes: ['leftZ', 'rightZ'] }, @@ -372,13 +408,14 @@ export const buildPickUpTips = ( export const buildIgnorePolicyRules = ( commandType: FailedCommand['commandType'], - errorType: string + errorType: string, + ifMatch: IfMatchType ): RecoveryPolicyRulesParams => { return [ { commandType, errorType, - ifMatch: 'ignoreAndContinue', + ifMatch, }, ] } diff --git a/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx b/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx index 54b8239da47..967a840ee75 100644 --- a/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx +++ b/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx @@ -65,6 +65,7 @@ describe('LiquidsLabwareDetailsModal', () => { vi.mocked(getLocationInfoNames).mockReturnValue({ labwareName: 'mock labware name', slotName: '5', + labwareQuantity: 1, }) vi.mocked(getSlotLabwareDefinition).mockReturnValue(mockDefinition) vi.mocked(getLiquidsByIdForLabware).mockReturnValue({ diff --git a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx index 16d3dec77d2..91696be776f 100644 --- a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx +++ b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' -import code from '/app/assets/images/module_instruction_code.png' +import helpCenterQRCode from '/app/assets/images/module_instruction_code.png' +import absorbanceReaderManualQRCode from '/app/assets/images/absorbance_reader_instruction_manual_code.png' import { ALIGN_FLEX_END, DIRECTION_COLUMN, @@ -17,14 +18,17 @@ import { import { getTopPortalEl } from '/app/App/portal' const MODULE_SETUP_URL = 'https://support.opentrons.com/s/modules' +const ABSORBANCE_READER_MANUAL_URL = + 'https://insights.opentrons.com/hubfs/Absorbance%20Plate%20Reader%20Instruction%20Manual.pdf' interface ModuleSetupModalProps { close: () => void moduleDisplayName: string + isAbsorbanceReader?: boolean } export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { - const { moduleDisplayName } = props + const { moduleDisplayName, isAbsorbanceReader } = props const { t, i18n } = useTranslation(['protocol_setup', 'shared', 'branded']) return createPortal( @@ -41,12 +45,18 @@ export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { width="50%" > - {t('branded:modal_instructions')} + {isAbsorbanceReader + ? t('module_instructions_manual') + : t('branded:modal_instructions')} { /> - + {i18n.format(t('shared:close'), 'capitalize')} diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx index 7f24a60bc7c..87f340b2845 100644 --- a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx @@ -47,4 +47,23 @@ describe('ModuleSetupModal', () => { fireEvent.click(closeButton) expect(props.close).toHaveBeenCalled() }) + it('should render variable copy and link if absorbance reader', () => { + props = { + ...props, + isAbsorbanceReader: true, + } + render(props) + screen.getByText( + 'For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to read the module Instruction Manual.' + ) + expect( + screen + .getByRole('link', { + name: 'mockModuleDisplayName setup instructions', + }) + .getAttribute('href') + ).toBe( + 'https://insights.opentrons.com/hubfs/Absorbance%20Plate%20Reader%20Instruction%20Manual.pdf' + ) + }) }) diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx index 21b6fb20854..339ad981daa 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx @@ -3,18 +3,22 @@ import { BaseDeck, Flex } from '@opentrons/components' import { FLEX_ROBOT_TYPE, getSimplestDeckConfigForProtocol, + getTopLabwareInfo, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' import { getStandardDeckViewLayerBlockList } from '/app/local-resources/deck_configuration' import { getLabwareRenderInfo } from '/app/transformations/analysis' +import type { LabwareOnDeck } from '@opentrons/components' import type { CompletedProtocolAnalysis, DeckDefinition, LabwareDefinition2, - LoadedLabwareByAdapter, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' + import type { AttachedProtocolModuleMatch } from '/app/transformations/analysis' interface LabwareMapViewProps { @@ -23,7 +27,6 @@ interface LabwareMapViewProps { labwareDef: LabwareDefinition2, labwareId: string ) => void - initialLoadedLabwareByAdapter: LoadedLabwareByAdapter deckDef: DeckDefinition mostRecentAnalysis: CompletedProtocolAnalysis | null } @@ -32,11 +35,16 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const { handleLabwareClick, attachedProtocolModuleMatches, - initialLoadedLabwareByAdapter, deckDef, mostRecentAnalysis, } = props const deckConfig = getSimplestDeckConfigForProtocol(mostRecentAnalysis) + const commands: RunTimeCommand[] = mostRecentAnalysis?.commands ?? [] + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + const labwareRenderInfo = mostRecentAnalysis != null ? getLabwareRenderInfo(mostRecentAnalysis, deckDef) @@ -44,16 +52,11 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const modulesOnDeck = attachedProtocolModuleMatches.map(module => { const { moduleDef, nestedLabwareDef, nestedLabwareId, slotName } = module - const labwareInAdapterInMod = - nestedLabwareId != null - ? initialLoadedLabwareByAdapter[nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? nestedLabwareId + const isLabwareStacked = nestedLabwareId != null && nestedLabwareDef != null + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + module.nestedLabwareId ?? '', + loadLabwareCommands + ) return { moduleModel: moduleDef.model, @@ -70,49 +73,48 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { } : undefined, highlightLabware: true, - highlightShadowLabware: - topLabwareDefinition != null && topLabwareId != null, + highlightShadowLabware: isLabwareStacked, moduleChildren: null, - stacked: topLabwareDefinition != null && topLabwareId != null, + stacked: isLabwareStacked, } }) - const labwareLocations = map( + const labwareLocations: Array = map( labwareRenderInfo, - ({ labwareDef, slotName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId - const isLabwareInStack = - topLabwareDefinition != null && - topLabwareId != null && - labwareInAdapter != null + ({ slotName }, labwareId) => { + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + labwareId, + loadLabwareCommands + ) + const isLabwareInStack = labwareId !== topLabwareId - return { - labwareLocation: { slotName }, - definition: topLabwareDefinition, - topLabwareId, - onLabwareClick: () => { - handleLabwareClick(topLabwareDefinition, topLabwareId) - }, - labwareChildren: null, - highlight: true, - highlightShadow: isLabwareInStack, - stacked: isLabwareInStack, - } + return topLabwareDefinition != null + ? { + labwareLocation: { slotName }, + definition: topLabwareDefinition, + onLabwareClick: () => { + handleLabwareClick(topLabwareDefinition, topLabwareId) + }, + highlight: true, + highlightShadow: isLabwareInStack, + stacked: isLabwareInStack, + } + : null } ) + const labwareLocationsFiltered: LabwareOnDeck[] = labwareLocations.filter( + (labwareLocation): labwareLocation is LabwareOnDeck => + labwareLocation != null + ) + return ( diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx index 8729ae0f811..860d927578e 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx @@ -114,7 +114,6 @@ describe('LabwareMapView', () => { handleLabwareClick: vi.fn(), deckDef: (deckDefFixture as unknown) as DeckDefinition, mostRecentAnalysis: ({} as unknown) as CompletedProtocolAnalysis, - initialLoadedLabwareByAdapter: {}, attachedProtocolModuleMatches: [ { ...mockProtocolModuleInfo[0], diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx index 1a54e2fc00d..2d440fc9516 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx @@ -25,10 +25,11 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getLabwareDefURI, - getLabwareDisplayName, + getTopLabwareInfo, getModuleDisplayName, HEATERSHAKER_MODULE_TYPE, - parseInitialLoadedLabwareByAdapter, + TC_MODULE_LOCATION_OT3, + THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import { useCreateLiveCommandMutation, @@ -38,8 +39,8 @@ import { import { FloatingActionButton, SmallButton } from '/app/atoms/buttons' import { ODDBackButton } from '/app/molecules/ODDBackButton' import { + getLocationInfoNames, getLabwareSetupItemGroups, - getNestedLabwareInfo, } from '/app/transformations/commands' import { getAttachedProtocolModuleMatches, @@ -56,15 +57,12 @@ import type { HeaterShakerCloseLatchCreateCommand, HeaterShakerOpenLatchCreateCommand, LabwareDefinition2, - LabwareLocation, LoadLabwareRunTimeCommand, + LabwareLocation, RunTimeCommand, } from '@opentrons/shared-data' import type { HeaterShakerModule, Modules } from '@opentrons/api-client' -import type { - LabwareSetupItem, - NestedLabwareInfo, -} from '/app/transformations/commands' +import type { LabwareSetupItem } from '/app/transformations/commands' import type { SetupScreens } from '../types' import type { AttachedProtocolModuleMatch } from '/app/transformations/analysis' @@ -121,9 +119,6 @@ export function ProtocolSetupLabware({ protocolModulesInfo, deckConfig ) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - mostRecentAnalysis?.commands ?? [] - ) const handleLabwareClick = ( labwareDef: LabwareDefinition2, @@ -152,7 +147,7 @@ export function ProtocolSetupLabware({ } } } - const selectedLabwareIsTopOfStack = mostRecentAnalysis?.commands.some( + const selectedLabwareIsStacked = mostRecentAnalysis?.commands.some( command => command.commandType === 'loadLabware' && command.result?.labwareId === selectedLabware?.id && @@ -164,7 +159,7 @@ export function ProtocolSetupLabware({ return ( <> {showLabwareDetailsModal && - !selectedLabwareIsTopOfStack && + !selectedLabwareIsStacked && selectedLabware != null ? ( ) : ( <> @@ -239,17 +233,14 @@ export function ProtocolSetupLabware({ 'labwareId' in labware.initialLocation && item.labwareId === labware.initialLocation.labwareId ) - return mostRecentAnalysis != null && labwareOnAdapter == null ? ( + return mostRecentAnalysis?.commands != null && + labwareOnAdapter == null ? ( ) : null })} @@ -257,7 +248,7 @@ export function ProtocolSetupLabware({ )} {showLabwareDetailsModal && selectedLabware != null && - selectedLabwareIsTopOfStack ? ( + selectedLabwareIsStacked ? ( ['refetch'] - nestedLabwareInfo: NestedLabwareInfo | null - commands?: RunTimeCommand[] + commands: RunTimeCommand[] } function RowLabware({ labware, attachedProtocolModules, refetchModules, - nestedLabwareInfo, commands, }: RowLabwareProps): JSX.Element | null { - const { definition, initialLocation, nickName } = labware + const { + initialLocation, + nickName: bottomLabwareNickname, + labwareId: bottomLabwareId, + } = labware + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + + const { topLabwareId } = getTopLabwareInfo( + bottomLabwareId ?? '', + loadLabwareCommands + ) + const { + slotName: slot, + labwareName: topLabwareName, + labwareNickname: topLabwareNickname, + labwareQuantity: topLabwareQuantity, + adapterName, + } = getLocationInfoNames(topLabwareId, commands) + const { t, i18n } = useTranslation([ 'protocol_command_text', 'protocol_setup', @@ -451,47 +461,21 @@ function RowLabware({ matchedModule.attachedModuleMatch.moduleType === HEATERSHAKER_MODULE_TYPE ? matchedModule.attachedModuleMatch : null + const isStacked = + topLabwareQuantity > 1 || adapterName != null || matchedModule != null - let slotName: string = '' - let location: JSX.Element | string | null = null + let slotName: string = slot + let location: JSX.Element = if (initialLocation === 'offDeck') { location = ( ) - } else if ('slotName' in initialLocation) { - slotName = initialLocation.slotName - location = - } else if ('addressableAreaName' in initialLocation) { - slotName = initialLocation.addressableAreaName - location = - } else if (labware.moduleLocation != null) { - location = ( - <> - - - ) - } else if ('labwareId' in initialLocation) { - const adapterId = initialLocation.labwareId - const adapterLocation = commands?.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === adapterId - )?.params.location - - if (adapterLocation != null && adapterLocation !== 'offDeck') { - if ('slotName' in adapterLocation) { - slotName = adapterLocation.slotName - location = - } else if ('moduleId' in adapterLocation) { - const moduleUnderAdapter = attachedProtocolModules.find( - module => module.moduleId === adapterLocation.moduleId - ) - if (moduleUnderAdapter != null) { - slotName = moduleUnderAdapter.slotName - location = - } - } - } + } else if ( + matchedModule != null && + matchedModule.attachedModuleMatch?.moduleType === THERMOCYCLER_MODULE_TYPE + ) { + slotName = TC_MODULE_LOCATION_OT3 + location = } return ( {location} - {nestedLabwareInfo != null || matchedModule != null ? ( - - ) : null} + {isStacked ? : null} - {getLabwareDisplayName(definition)} + {topLabwareName} - {nickName} + {topLabwareQuantity > 1 + ? t('protocol_setup:labware_quantity', { + quantity: topLabwareQuantity, + }) + : topLabwareNickname} - {nestedLabwareInfo != null && - nestedLabwareInfo?.sharedSlotId === slotName ? ( + {adapterName != null ? ( <> - {nestedLabwareInfo.nestedLabwareDisplayName} + {adapterName} - {nestedLabwareInfo.nestedLabwareNickName} + {bottomLabwareNickname} diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx index feeb3e863a4..720b6db7545 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx @@ -43,6 +43,7 @@ describe('LiquidDetails', () => { vi.mocked(getLocationInfoNames).mockReturnValue({ slotName: '4', labwareName: 'mock labware name', + labwareQuantity: 1, }) vi.mocked(LiquidsLabwareDetailsModal).mockReturnValue(
mock modal
) }) diff --git a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx index 7450fb34e4e..2ba0d50ea3b 100644 --- a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx +++ b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx @@ -40,16 +40,19 @@ describe('useRunControls hook', () => { const mockStopRun = vi.fn() const mockCloneRun = vi.fn() const mockResumeRunFromRecovery = vi.fn() + const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn() when(useRunActionMutations).calledWith(mockPausedRun.id).thenReturn({ playRun: mockPlayRun, pauseRun: mockPauseRun, stopRun: mockStopRun, resumeRunFromRecovery: mockResumeRunFromRecovery, + resumeRunFromRecoveryAssumingFalsePositive: mockResumeRunFromRecoveryAssumingFalsePositive, isPlayRunActionLoading: false, isPauseRunActionLoading: false, isStopRunActionLoading: false, isResumeRunFromRecoveryActionLoading: false, + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading: false, }) when(useCloneRun).calledWith(mockPausedRun.id, undefined, true).thenReturn({ cloneRun: mockCloneRun, diff --git a/app/src/pages/Desktop/Labware/__tests__/Labware.test.tsx b/app/src/pages/Desktop/Labware/__tests__/Labware.test.tsx index 3c919b7f295..40cfb02c57c 100644 --- a/app/src/pages/Desktop/Labware/__tests__/Labware.test.tsx +++ b/app/src/pages/Desktop/Labware/__tests__/Labware.test.tsx @@ -121,6 +121,9 @@ describe('Labware', () => { screen.getByRole('button', { name: 'Tube Rack' }) screen.getByRole('button', { name: 'Reservoir' }) screen.getByRole('button', { name: 'Aluminum Block' }) + screen.getByRole('button', { name: 'Adapter' }) + screen.getByRole('button', { name: 'Lid' }) + screen.getByRole('button', { name: 'Custom Labware' }) }) it('renders changes filter menu button when an option is selected', () => { render() diff --git a/app/src/pages/Desktop/Labware/index.tsx b/app/src/pages/Desktop/Labware/index.tsx index 159e57c306e..83f9dd94f3f 100644 --- a/app/src/pages/Desktop/Labware/index.tsx +++ b/app/src/pages/Desktop/Labware/index.tsx @@ -56,6 +56,7 @@ const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'adapter', 'aluminumBlock', 'customLabware', + 'lid', 'reservoir', 'tipRack', 'tubeRack', diff --git a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx index 2605c1bad5b..f98666d3cbd 100644 --- a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -76,6 +76,7 @@ const mockPlayRun = vi.fn() const mockPauseRun = vi.fn() const mockStopRun = vi.fn() const mockResumeRunFromRecovery = vi.fn() +const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn() const render = (path = '/') => { return renderWithProviders( @@ -133,10 +134,12 @@ describe('RunningProtocol', () => { pauseRun: mockPauseRun, stopRun: mockStopRun, resumeRunFromRecovery: mockResumeRunFromRecovery, + resumeRunFromRecoveryAssumingFalsePositive: mockResumeRunFromRecoveryAssumingFalsePositive, isPlayRunActionLoading: false, isPauseRunActionLoading: false, isStopRunActionLoading: false, isResumeRunFromRecoveryActionLoading: false, + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading: false, }) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) diff --git a/app/src/resources/modules/hooks/index.ts b/app/src/resources/modules/hooks/index.ts index c38e5f46140..c26e43c8bfc 100644 --- a/app/src/resources/modules/hooks/index.ts +++ b/app/src/resources/modules/hooks/index.ts @@ -1 +1,2 @@ export * from './useAttachedModules' +export * from './usePlacePlateReaderLid' diff --git a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts new file mode 100644 index 00000000000..0e4dabcb660 --- /dev/null +++ b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts @@ -0,0 +1,82 @@ +import { useRunCurrentState } from '@opentrons/react-api-client' +import { useCurrentRunId } from '../../runs' +import { useRobotControlCommands } from '/app/resources/maintenance_runs' + +import type { + CreateCommand, + OnDeckLabwareLocation, + ModuleLocation, +} from '@opentrons/shared-data' +import type { UseRobotControlCommandsProps } from '/app/resources/maintenance_runs' + +interface UsePlacePlateReaderLidResult { + handlePlaceReaderLid: () => Promise + isExecuting: boolean + isValidPlateReaderMove: boolean +} + +type UsePlacePlateReaderLidProps = Pick< + UseRobotControlCommandsProps, + 'onSettled' +> + +export function usePlacePlateReaderLid( + props: UsePlacePlateReaderLidProps +): UsePlacePlateReaderLidResult { + const runId = useCurrentRunId() + const { data: runCurrentState } = useRunCurrentState(runId) + + const placeLabware = runCurrentState?.data.placeLabwareState ?? null + const isValidPlateReaderMove = + placeLabware !== null && placeLabware.shouldPlaceDown + + // TODO eventually load module support for useRobotControlCommands + let commandsToExecute: CreateCommand[] = [] + if (isValidPlateReaderMove) { + const location = placeLabware.location + const loadModuleCommand = buildLoadModuleCommand(location as ModuleLocation) + const placeLabwareCommand = buildPlaceLabwareCommand( + placeLabware.labwareId as string, + location + ) + commandsToExecute = [loadModuleCommand, placeLabwareCommand] + } + + const { executeCommands, isExecuting } = useRobotControlCommands({ + ...props, + pipetteInfo: null, + commands: commandsToExecute, + continuePastCommandFailure: true, + }) + + const handlePlaceReaderLid = (): Promise => { + if (isValidPlateReaderMove) { + return executeCommands().then(() => Promise.resolve()) + } else { + return Promise.resolve() + } + } + + return { + handlePlaceReaderLid, + isExecuting, + isValidPlateReaderMove, + } +} + +const buildLoadModuleCommand = (location: ModuleLocation): CreateCommand => { + return { + commandType: 'loadModule' as const, + params: { model: 'absorbanceReaderV1', location }, + } +} + +const buildPlaceLabwareCommand = ( + labwareId: string, + location: OnDeckLabwareLocation +): CreateCommand => { + return { + commandType: 'unsafe/placeLabware' as const, + params: { labwareId, location }, + } +} diff --git a/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts b/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts index d0b3551972f..f722caeb076 100644 --- a/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts +++ b/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts @@ -1,5 +1,8 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' -import { getLabwareDisplayName } from '@opentrons/shared-data' +import { + getLabwareDisplayName, + getLabwareStackCountAndLocation, +} from '@opentrons/shared-data' import { getLocationInfoNames } from '../getLocationInfoNames' import type { ModuleModel } from '@opentrons/shared-data' @@ -154,11 +157,16 @@ vi.mock('@opentrons/shared-data') describe('getLocationInfoNames', () => { beforeEach(() => { vi.mocked(getLabwareDisplayName).mockReturnValue(LABWARE_DISPLAY_NAME) + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { slotName: SLOT }, + labwareQuantity: 1, + }) }) it('returns labware name and slot number for labware id on the deck', () => { const expected = { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, + labwareQuantity: 1, } expect( getLocationInfoNames(LABWARE_ID, MOCK_LOAD_LABWARE_COMMANDS as any) @@ -169,7 +177,12 @@ describe('getLocationInfoNames', () => { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, moduleModel: MOCK_MODEL, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { moduleId: '12345' }, + labwareQuantity: 1, + }) expect(getLocationInfoNames(LABWARE_ID, MOCK_MOD_COMMANDS as any)).toEqual( expected ) @@ -181,7 +194,12 @@ describe('getLocationInfoNames', () => { moduleModel: MOCK_MODEL, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_MOD_COMMANDS as any) ).toEqual(expected) @@ -192,7 +210,12 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) ).toEqual(expected) @@ -203,7 +226,12 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_EXTENSION_COMMANDS as any) ).toEqual(expected) diff --git a/app/src/transformations/commands/transformations/getLocationInfoNames.ts b/app/src/transformations/commands/transformations/getLocationInfoNames.ts index 26d618859f9..e87f5d68ba8 100644 --- a/app/src/transformations/commands/transformations/getLocationInfoNames.ts +++ b/app/src/transformations/commands/transformations/getLocationInfoNames.ts @@ -1,4 +1,8 @@ -import { getLabwareDisplayName } from '@opentrons/shared-data' +import { + getLabwareDisplayName, + getLabwareStackCountAndLocation, +} from '@opentrons/shared-data' + import type { LoadLabwareRunTimeCommand, RunTimeCommand, @@ -10,6 +14,7 @@ export interface LocationInfoNames { slotName: string labwareName: string labwareNickname?: string + labwareQuantity: number adapterName?: string moduleModel?: ModuleModel adapterId?: string @@ -30,11 +35,11 @@ export function getLocationInfoNames( (command): command is LoadModuleRunTimeCommand => command.commandType === 'loadModule' ) - if (loadLabwareCommand == null) { + if (loadLabwareCommands == null || loadLabwareCommand == null) { console.warn( `could not find the load labware command assosciated with thie labwareId: ${labwareId}` ) - return { slotName: '', labwareName: '' } + return { slotName: '', labwareName: '', labwareQuantity: 0 } } const labwareName = @@ -43,14 +48,21 @@ export function getLocationInfoNames( : '' const labwareNickname = loadLabwareCommand.params.displayName - const labwareLocation = loadLabwareCommand.params.location + const { labwareLocation, labwareQuantity } = getLabwareStackCountAndLocation( + labwareId, + loadLabwareCommands + ) if (labwareLocation === 'offDeck') { - return { slotName: 'Off deck', labwareName } + return { slotName: 'Off deck', labwareName, labwareQuantity } } else if ('slotName' in labwareLocation) { - return { slotName: labwareLocation.slotName, labwareName } + return { slotName: labwareLocation.slotName, labwareName, labwareQuantity } } else if ('addressableAreaName' in labwareLocation) { - return { slotName: labwareLocation.addressableAreaName, labwareName } + return { + slotName: labwareLocation.addressableAreaName, + labwareName, + labwareQuantity, + } } else if ('moduleId' in labwareLocation) { const loadModuleCommandUnderLabware = loadModuleCommands?.find( command => command.result?.moduleId === labwareLocation.moduleId @@ -62,9 +74,11 @@ export function getLocationInfoNames( loadModuleCommandUnderLabware?.params.location.slotName ?? '', labwareName, moduleModel: loadModuleCommandUnderLabware?.params.model, + labwareQuantity, } - : { slotName: '', labwareName: '' } + : { slotName: '', labwareName: '', labwareQuantity } } else { + // adapt this to return the adapter only if the role of this labware is adapter -- otherwise, keep parsing through until you find out how many identical labware there are const loadedAdapterCommand = loadLabwareCommands?.find(command => command.result != null ? command.result?.labwareId === labwareLocation.labwareId @@ -74,7 +88,7 @@ export function getLocationInfoNames( console.warn( `expected to find an adapter under the labware but could not with labwareId ${labwareLocation.labwareId}` ) - return { slotName: '', labwareName: labwareName } + return { slotName: '', labwareName: labwareName, labwareQuantity } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && 'slotName' in loadedAdapterCommand?.params.location @@ -86,6 +100,7 @@ export function getLocationInfoNames( adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, + labwareQuantity, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -98,6 +113,7 @@ export function getLocationInfoNames( adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, + labwareQuantity, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -118,11 +134,12 @@ export function getLocationInfoNames( loadedAdapterCommand.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, moduleModel: loadModuleCommandUnderAdapter.params.model, + labwareQuantity, } - : { slotName: '', labwareName } + : { slotName: '', labwareName, labwareQuantity } } else { // shouldn't hit this - return { slotName: '', labwareName } + return { slotName: '', labwareName, labwareQuantity } } } } diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx new file mode 100644 index 00000000000..d6685e0793c --- /dev/null +++ b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx @@ -0,0 +1,100 @@ +// x .32, y .31 +export function OpentronsAutoclavableDeckRiser(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx new file mode 100644 index 00000000000..b3f50f94dd0 --- /dev/null +++ b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx @@ -0,0 +1,83 @@ +export function OpentronsToughPCRAutoSealingLid(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx index f3941d980af..417b83ce89c 100644 --- a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx +++ b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx @@ -3,6 +3,8 @@ import { Opentrons96FlatBottomAdapter } from './Opentrons96FlatBottomAdapter' import { OpentronsUniversalFlatAdapter } from './OpentronsUniversalFlatAdapter' import { OpentronsAluminumFlatBottomPlate } from './OpentronsAluminumFlatBottomPlate' import { OpentronsFlex96TiprackAdapter } from './OpentronsFlex96TiprackAdapter' +import { OpentronsToughPCRAutoSealingLid } from './OpentronsToughPCRAutoSealingLid' +import { OpentronsAutoclavableDeckRiser } from './OpentronsAutoclavableDeckRiser' import { COLORS } from '../../../helix-design-system' import { LabwareOutline } from '../labwareInternals' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -13,6 +15,8 @@ const LABWARE_ADAPTER_LOADNAME_PATHS = { opentrons_aluminum_flat_bottom_plate: OpentronsAluminumFlatBottomPlate, opentrons_flex_96_tiprack_adapter: OpentronsFlex96TiprackAdapter, opentrons_universal_flat_adapter: OpentronsUniversalFlatAdapter, + opentrons_tough_pcr_auto_sealing_lid: OpentronsToughPCRAutoSealingLid, + opentrons_flex_deck_riser: OpentronsAutoclavableDeckRiser, } export type LabwareAdapterLoadName = keyof typeof LABWARE_ADAPTER_LOADNAME_PATHS diff --git a/components/src/hardware-sim/ProtocolDeck/index.tsx b/components/src/hardware-sim/ProtocolDeck/index.tsx index fb1ac06349b..20c3a0c990b 100644 --- a/components/src/hardware-sim/ProtocolDeck/index.tsx +++ b/components/src/hardware-sim/ProtocolDeck/index.tsx @@ -4,7 +4,7 @@ import { FLEX_ROBOT_TYPE, getLabwareDisplayName, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, } from '@opentrons/shared-data' import { BaseDeck } from '../BaseDeck' @@ -19,6 +19,8 @@ import type { CompletedProtocolAnalysis, LabwareDefinition2, ProtocolAnalysisOutput, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' export * from './utils/getStandardDeckViewLayerBlockList' @@ -46,13 +48,15 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { if (protocolAnalysis == null || (protocolAnalysis?.errors ?? []).length > 0) return null + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const labwareByLiquidId = getLabwareInfoByLiquidId(protocolAnalysis.commands) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - protocolAnalysis.commands - ) const modulesInSlots = getModulesInSlots(protocolAnalysis) const modulesOnDeck = modulesInSlots.map( @@ -63,16 +67,10 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { nestedLabwareDef, nestedLabwareNickName, }) => { - const labwareInAdapterInMod = - nestedLabwareId != null - ? initialLoadedLabwareByAdapter[nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? nestedLabwareId + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + nestedLabwareId ?? '', + loadLabwareCommands + ) return { moduleModel, @@ -112,15 +110,16 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { } ) + // this function gets the top labware assuming a stack of max 2 labware const topMostLabwareInSlots = getTopMostLabwareInSlots(protocolAnalysis) const labwareOnDeck = topMostLabwareInSlots.map( ({ labwareId, labwareDef, labwareNickName, location }) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId + // this gets the very top of the stack in case there is a stack + // of many like items, such as TC lids + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + labwareId, + loadLabwareCommands + ) const isLabwareInStack = protocolAnalysis?.commands.some( command => command.commandType === 'loadLabware' && @@ -146,7 +145,7 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { highlight: handleLabwareClick != null, highlightShadow: handleLabwareClick != null && isLabwareInStack, onLabwareClick: - handleLabwareClick != null + handleLabwareClick != null && topLabwareDefinition != null ? () => { handleLabwareClick(topLabwareDefinition, topLabwareId) } diff --git a/protocol-designer/src/assets/images/tip_side_bottom_layer.svg b/protocol-designer/src/assets/images/tip_side_bottom_layer.svg new file mode 100644 index 00000000000..717282ed951 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_bottom_layer.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_side_mid_layer.svg b/protocol-designer/src/assets/images/tip_side_mid_layer.svg new file mode 100644 index 00000000000..097f163b705 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_mid_layer.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_side_top_layer.svg b/protocol-designer/src/assets/images/tip_side_top_layer.svg new file mode 100644 index 00000000000..adf16b11691 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_top_layer.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_bottom_layer.svg b/protocol-designer/src/assets/images/tip_top_bottom_layer.svg new file mode 100644 index 00000000000..9def3aa184d --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_bottom_layer.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_mid_layer.svg b/protocol-designer/src/assets/images/tip_top_mid_layer.svg new file mode 100644 index 00000000000..7e97da22d38 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_mid_layer.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_top_layer.svg b/protocol-designer/src/assets/images/tip_top_top_layer.svg new file mode 100644 index 00000000000..d5c7edbbc9f --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_top_layer.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/well-order_b2t-l2r.jpg b/protocol-designer/src/assets/images/well-order_b2t-l2r.jpg new file mode 100644 index 00000000000..e65a19f136c Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_b2t-l2r.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_b2t-r2l.jpg b/protocol-designer/src/assets/images/well-order_b2t-r2l.jpg new file mode 100644 index 00000000000..bc2ee4bbb96 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_b2t-r2l.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_l2r-b2t.jpg b/protocol-designer/src/assets/images/well-order_l2r-b2t.jpg new file mode 100644 index 00000000000..f8cf9af40d7 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_l2r-b2t.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_l2r-t2b.jpg b/protocol-designer/src/assets/images/well-order_l2r-t2b.jpg new file mode 100644 index 00000000000..ab69dc2f3d9 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_l2r-t2b.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_r2l-b2t.jpg b/protocol-designer/src/assets/images/well-order_r2l-b2t.jpg new file mode 100644 index 00000000000..fccced73986 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_r2l-b2t.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_r2l-t2b.jpg b/protocol-designer/src/assets/images/well-order_r2l-t2b.jpg new file mode 100644 index 00000000000..ee0a7719b63 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_r2l-t2b.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_t2b-l2r.jpg b/protocol-designer/src/assets/images/well-order_t2b-l2r.jpg new file mode 100644 index 00000000000..7ae3cb632f6 Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_t2b-l2r.jpg differ diff --git a/protocol-designer/src/assets/images/well-order_t2b-r2l.jpg b/protocol-designer/src/assets/images/well-order_t2b-r2l.jpg new file mode 100644 index 00000000000..ad820d1d32a Binary files /dev/null and b/protocol-designer/src/assets/images/well-order_t2b-r2l.jpg differ diff --git a/protocol-designer/src/assets/localization/en/alert.json b/protocol-designer/src/assets/localization/en/alert.json index 248d70a0aec..ece60e77570 100644 --- a/protocol-designer/src/assets/localization/en/alert.json +++ b/protocol-designer/src/assets/localization/en/alert.json @@ -59,6 +59,12 @@ "body2": "7.3.0 or higher", "body3": ". Please ensure your robot is updated to the correct version." }, + "unused_hardware": { + "title": "Protocol has unused hardware" + }, + "no_commands": { + "title": "Protocol has no steps" + }, "change_magnet_module_model": { "title": "All existing engage heights will be cleared" }, @@ -269,10 +275,42 @@ "no_commands": { "heading": "Your protocol has no steps", "body1": "This protocol has no steps in it- there's nothing for the robot to do! Before trying to run this on your robot add at least one step between your Starting Deck State and Final Deck State.", - "body2": "Learn more about building steps ", - "redesign": { + "body2": "Learn more about building steps " + }, + "redesign": { + "unused_hardware": "Unused hardware:", + "unused_module": "{{module}} in slot {{slot}}", + "unused_pipette": "{{pipette}} on {{mount}} mount", + "unused_staging_area": "Staging area in slot {{slot}}", + "no_commands": { "heading": "Protocol has no steps", - "body": "This protocol has no steps. Before trying to run this protocol on your robot, add at least one step." + "body1": "This protocol has no steps. Before trying to run this protocol on your robot, add at least one step." + }, + "unused_gripper": { + "heading": "Protocol has unused gripper", + "body1": "The Flex Gripper is not used in any step. You won't be able to run this protocol unless the gripper is attached to your robot.", + "body2": "If you don’t intend to use the gripper, remove it from your protocol." + }, + "unused_hardware_content": { + "heading": "Protocol has unused hardware", + "body1": "The hardware listed below is not used in any step. You won't be able to run this protocol unless the hardware is attached to your robot.", + "body2": "If you don’t intend to use this hardware, remove it from your protocol." + }, + "unused_module_content": { + "heading": "Protocol has unused module", + "body1": "The {{module}} in slot {{slot}} is not used in any step. You won't be able to run this protocol unless the module is attached to your robot.", + "body2": "If you don’t intend to use the module, remove it from your protocol." + }, + "unused_pipette_content": { + "heading": "Protocol has unused pipette", + "body1": "The {{pipette}} on {{mount}} mount is currently not used in any step. You won't be able to run this protocol unless this pipette is attached to your robot.", + "body1_96ch": "The Flex 8-Channel 1000 μL is currently not used in any step. You won't be able to run this protocol unless this pipette is attached to your robot.", + "body2": "If you don’t intend to use the pipette, remove it from your protocol." + }, + "unused_staging_area_content": { + "heading": "Protocol has unused staging area", + "body1": "The staging area in {{slot}} is not used in any step. You won't be able to run this protocol unless it is in your robot's deck configuration.", + "body2": "If you don’t intend to use the staging area, remove it from your protocol." } }, "unused_pipette_and_module": { diff --git a/protocol-designer/src/assets/localization/en/modal.json b/protocol-designer/src/assets/localization/en/modal.json index ebf4e0d9b80..8b891c0f405 100644 --- a/protocol-designer/src/assets/localization/en/modal.json +++ b/protocol-designer/src/assets/localization/en/modal.json @@ -52,11 +52,10 @@ "body6": "All protocols require {{app}} version 7.3.0 or later to run." }, "redesign": { - "body1": "Welcome to Protocol Designer 9.0.0!", - "body2": "We’re excited to release the new Opentrons Protocol Designer, now with a fresh redesign! Enjoy the same functionality with some powerful new features:", - "body3": "Easily group multiple steps together, name the group, and keep your protocols organized with step grouping.", - "body4": "Add multiple Heater-Shaker Modules and Magnetic Blocks to the deck (Flex only).", - "body5": "All protocols now require Opentrons App version 8.0.0+ to run." + "body1": "Welcome to Protocol Designer 8.2.0!", + "body2": "We’re excited to release the new Opentrons Protocol Designer, now with a fresh redesign! Enjoy the same functionality with the added ability to:", + "body3": "Add multiple Heater-Shaker Modules and Magnetic Blocks to the deck (Flex only).", + "body4": "All protocols now require Opentrons App version 8.0.0+ to run." } }, "labware_selection": { @@ -77,7 +76,7 @@ "tip_position": { "title": "Tip Positioning", "caption": "between {{min}} and {{max}}", - "warning": "One or more position offset values are close to the edge of the well and might collide with it", + "warning": "Tip position is close to the edge of the well and may cause collisions.", "radio_button": { "default": "{{defaultMmFromBottom}} mm from the bottom center (default)", "blowout": "0 mm from the top center (default)", @@ -85,15 +84,15 @@ "custom": "Custom" }, "body": { - "blowout_z_offset": "Change from where in the well the robot emits blowout", - "aspirate_mmFromBottom": "Change from where in the well the robot aspirates", - "dispense_mmFromBottom": "Change from where in the well the robot dispenses", - "mix_mmFromBottom": "Change from where in the well the robot aspirates and dispenses during the mix", - "aspirate_touchTip_mmFromBottom": "Change from where in the well the robot performs touch tip", - "dispense_touchTip_mmFromBottom": "Change from where in the well the robot performs touch tip", - "mix_touchTip_mmFromBottom": "Change from where in the well the robot performs touch tip", - "aspirate_delay_mmFromBottom": "Change from where in the well the robot delays after aspirating", - "dispense_delay_mmFromBottom": "Change from where in the well the robot delays after dispensing" + "blowout_z_offset": "Change where in the well the robot performs the blowout.", + "aspirate_mmFromBottom": "Change where in the well the robot aspirates from.", + "dispense_mmFromBottom": "Change where in the well the robot dispenses from.", + "mix_mmFromBottom": "Change from where in the well the robot aspirates and dispenses during the mix.", + "aspirate_touchTip_mmFromBottom": "Change from where in the well the robot performs the touch tip.", + "dispense_touchTip_mmFromBottom": "Change from where in the well the robot performs the touch tip.", + "mix_touchTip_mmFromBottom": "Change from where in the well the robot performs the touch tip.", + "aspirate_delay_mmFromBottom": "Change from where in the well the robot delays after aspirating.", + "dispense_delay_mmFromBottom": "Change from where in the well the robot delays after dispensing." }, "field_titles": { "z_position": "Z position", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 4f7e3a00ed6..ea0339978ec 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -10,6 +10,7 @@ "blowout_location": "Blowout location", "blowout_position": "Blowout position from bottom", "change_tips": "Change tips", + "default_flow_rate": "The default flow rate is {{flowRate}}", "default_tip_option": "Default - get next tip", "delay_duration": "Delay duration", "delay_position": "Delay position from bottom", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index b98f29e6beb..dafebf2b62d 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -1,26 +1,28 @@ { "add": "add", "agree": "Agree", - "analytics_tracking": "I consent to analytics tracking:", "amount": "Amount:", + "analytics_tracking": "I consent to analytics tracking:", "app_settings": "App settings", "ask_for_labware_overwrite": "Duplicate labware name", "back": "Back", "cancel": "Cancel", + "change_robot_movement": "Change how the robot moves from well to well.", "close": "Close", "confirm_import": "Are you sure you want to upload this protocol?", "confirm_reorder": "Are you sure you want to reorder these steps, it may cause errors?", "confirm": "Confirm", + "consent_to_eula": "By using Protocol Designer, you consent to the Opentrons EULA.", "create_a_protocol": "Create a protocol", "create_new": "Create new", "destination_well": "Destination Well", "developer_ff": "Developer feature flags", "done": "Done", - "pipette": "Pipette", "edit_existing": "Edit existing protocol", "edit_instruments": "Edit Instruments", "edit_pipette": "Edit Pipette", "edit_protocol_metadata": "Edit protocol metadata", + "edit_well_order": "Edit well order", "edit": "edit", "eight_channel": "8-Channel", "error_boundary_pd_app_description": "You need to reload the app. Contact support with the following error message:", @@ -98,22 +100,27 @@ "only_tiprack": "Incompatible file type", "opentrons_flex": "Opentrons Flex", "opentrons": "Opentrons", + "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.", "ot2": "Opentrons OT-2", "overwrite_labware": "Overwrite labware", "overwrite": "Click Overwrite to replace the existing labware with the new labware.", + "pipette": "Pipette", "pd_version": "Protocol designer version", + "primary_order": "Primary order", "privacy": "Privacy", "protocol_designer": "Protocol Designer", "re_export": "To use this definition, use Labware Creator to give it a unique load name and display name.", - "remove": "remove", "reject": "Reject", "reload_app": "Reload app", + "remove": "remove", "reset_hints_and_tips": "Reset all hints and tips notifications", "reset_hints": "Reset hints", + "reset_to_default": "Reset to default", "resize_your_browser": "Resize your browser to at least 600px wide and 650px tall to continue editing your protocol", "review_our_privacy_policy": "You can adjust this setting at any time by clicking on the settings icon. Find detailed information in our privacy policy.", "right": "Right", "save": "Save", + "secondary_order": "Secondary order", "settings": "Settings", "shared_display_name": "Shared display name: ", "shared_load_name": "Shared load name: ", @@ -124,9 +131,16 @@ "stagingArea": "Staging area", "step_count": "Step {{current}}", "step": "Step {{current}} / {{max}}", - "consent_to_eula": "By using Protocol Designer, you consent to the Opentrons EULA.", + "swap_view": "Swap view", "temperaturemoduletype": "Temperature Module", "thermocyclermoduletype": "Thermocycler Module", + "tip_position_aspirate_delay_mmFromBottom": "Edit aspirate delay position", + "tip_position_aspirate_touchTip_mmFromBottom": "Edit aspirate touch tip position", + "tip_position_blowout_z_offset": "Edit blowout position", + "tip_position_dispense_delay_mmFromBottom": "Edit dispense delay position", + "tip_position_dispense_touchTip_mmFromBottom": "Edit dispense touch tip position", + "tip_position_mix_touchTip_mmFromBottom": "Edit touch tip position", + "tip_position": "Edit {{prefix}} tip position", "trashBin": "Trash Bin", "updated_protocol_designer": "We've updated Protocol Designer!", "user_settings": "User settings", @@ -138,7 +152,6 @@ "wasteChuteAndStagingArea": "Waste chute and staging area slot", "we_are_improving": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products. Find detailed information in our privacy policy. By using Protocol Designer, you consent to the Opentrons EULA.", "welcome": "Welcome to Protocol Designer!", - "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.", "yes": "Yes", "your_screen_is_too_small": "Your browser size is too small" } diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx index 00c59baae82..44443796a90 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx @@ -50,7 +50,7 @@ describe('TipPositionModal', () => { it('renders the modal text and radio button text', () => { render(props) screen.getByText('Tip Positioning') - screen.getByText('Change from where in the well the robot aspirates') + screen.getByText('Change where in the well the robot aspirates from.') screen.getByRole('radio', { name: '1 mm from the bottom center (default)' }) screen.getByRole('radio', { name: 'Custom' }) fireEvent.click(screen.getByText('cancel')) @@ -66,7 +66,7 @@ describe('TipPositionModal', () => { render(props) screen.getByText('warning') screen.getByText( - 'One or more position offset values are close to the edge of the well and might collide with it' + 'Tip position is close to the edge of the well and may cause collisions.' ) }) it('renders the alert if the x/y position values are too close to the max/min for y value', () => { @@ -74,7 +74,7 @@ describe('TipPositionModal', () => { render(props) screen.getByText('warning') screen.getByText( - 'One or more position offset values are close to the edge of the well and might collide with it' + 'Tip position is close to the edge of the well and may cause collisions.' ) }) it('renders the custom options, captions, and visual', () => { diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx index 5db7590ed09..837739cdc02 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx @@ -31,7 +31,7 @@ describe('ZTipPositionModal', () => { it('renders the text and radio buttons', () => { render(props) screen.getByText('Tip Positioning') - screen.getByText('Change from where in the well the robot emits blowout') + screen.getByText('Change where in the well the robot performs the blowout.') screen.getByRole('radio', { name: '0 mm from the top center (default)' }) screen.getByRole('radio', { name: 'Custom' }) fireEvent.click(screen.getByText('cancel')) diff --git a/protocol-designer/src/molecules/InputStepFormField/index.tsx b/protocol-designer/src/molecules/InputStepFormField/index.tsx index 93dde06295f..8048d136e62 100644 --- a/protocol-designer/src/molecules/InputStepFormField/index.tsx +++ b/protocol-designer/src/molecules/InputStepFormField/index.tsx @@ -1,13 +1,16 @@ import { useTranslation } from 'react-i18next' import { Flex, InputField, SPACING } from '@opentrons/components' +import type { Dispatch, SetStateAction } from 'react' import type { FieldProps } from '../../components/StepEditForm/types' interface InputStepFormFieldProps extends FieldProps { title: string + setIsPristine?: Dispatch> units?: string padding?: string showTooltip?: boolean caption?: string + formLevelError?: string | null } export function InputStepFormField( @@ -26,6 +29,8 @@ export function InputStepFormField( padding = SPACING.spacing16, tooltipContent, caption, + formLevelError, + setIsPristine, ...otherProps } = props const { t } = useTranslation('tooltip') @@ -37,14 +42,18 @@ export function InputStepFormField( tooltipText={ showTooltip ? t(`${tooltipContent}`) ?? undefined : undefined } + type="number" title={title} caption={caption} name={name} - error={errorToShow} + error={formLevelError ?? errorToShow} onBlur={onFieldBlur} onFocus={onFieldFocus} onChange={e => { updateValue(e.currentTarget.value) + if (setIsPristine != null) { + setIsPristine(false) + } }} value={value ? String(value) : null} units={units} diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index 05e3883e575..3bb253dca9d 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -26,6 +26,7 @@ interface ToggleExpandStepFormFieldProps extends FieldProps { offLabel?: string caption?: string toggleElement?: 'toggle' | 'checkbox' + formLevelError?: string | null } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps diff --git a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx index 9ce244c7d57..eaa7c371310 100644 --- a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx @@ -52,13 +52,16 @@ export function ToggleStepFormField( {title} + {tooltipContent != null ? ( + {tooltipContent} + ) : null} {isSelected ? onLabel : offLabel} @@ -76,9 +79,6 @@ export function ToggleStepFormField( - {tooltipContent != null ? ( - {tooltipContent} - ) : null} ) } diff --git a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx index d08e25e1ec6..ad470253e69 100644 --- a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx @@ -28,12 +28,14 @@ import type { ProfileFormError } from '../../steplist/formLevel/profileErrors' import type { MakeAlert } from './types' interface FormAlertsProps { + showFormErrorsAndWarnings: boolean focusedField?: StepFieldName | null dirtyFields?: StepFieldName[] } function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { - const { focusedField, dirtyFields } = props + const { showFormErrorsAndWarnings, focusedField, dirtyFields } = props + const { t } = useTranslation('alert') const dispatch = useDispatch() const formLevelErrorsForUnsavedForm = useSelector( @@ -120,10 +122,12 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null {
) + const formErrors = [ ...visibleFormErrors.map(error => ({ title: error.title, - description: error.body || null, + description: error.body ?? null, + showAtForm: error.showAtForm ?? true, })), ...visibleDynamicFieldFormErrors.map(error => ({ title: error.title, @@ -155,14 +159,26 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { ) } } - return [...formErrors, ...formWarnings, ...timelineWarnings].length > 0 ? ( + + if (showFormErrorsAndWarnings) { + return [...formErrors, ...formWarnings].length > 0 ? ( + + {formErrors.map((error, key) => makeAlert('error', error, key))} + {formWarnings.map((warning, key) => makeAlert('warning', warning, key))} + + ) : null + } + + return timelineWarnings.length > 0 ? ( - {formErrors.map((error, key) => makeAlert('error', error, key))} - {formWarnings.map((warning, key) => makeAlert('warning', warning, key))} {timelineWarnings.map((warning, key) => makeAlert('warning', warning, key) )} diff --git a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx index b069da5534f..26c5cdb02ce 100644 --- a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx +++ b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx @@ -37,6 +37,7 @@ describe('FormAlerts', () => { props = { focusedField: null, dirtyFields: [], + showFormErrorsAndWarnings: false, } vi.mocked(getFormLevelErrorsForUnsavedForm).mockReturnValue([]) vi.mocked(getFormWarningsForSelectedStep).mockReturnValue([]) @@ -62,6 +63,7 @@ describe('FormAlerts', () => { expect(vi.mocked(dismissTimelineWarning)).toHaveBeenCalled() }) it('renders a form level warning that is dismissible', () => { + props.showFormErrorsAndWarnings = true vi.mocked(getFormWarningsForSelectedStep).mockReturnValue([ { type: 'TIP_POSITIONED_LOW_IN_TUBE', diff --git a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx index 38b16bf95aa..b06dad7d704 100644 --- a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx +++ b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx @@ -317,19 +317,13 @@ export const useAnnouncements = (): Announcement[] => { {t('announcements.redesign.body3')} - -
  • - - {t('announcements.redesign.body4')} - -
  • }} - i18nKey={'announcements.redesign.body5'} + i18nKey={'announcements.redesign.body4'} /> diff --git a/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx index b92295ff060..ee63ef87848 100644 --- a/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx +++ b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx @@ -29,7 +29,6 @@ describe('BlockingHintModal', () => { render(props) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(props.handleCancel).toHaveBeenCalled() - expect(vi.mocked(removeHint)).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(props.handleContinue).toHaveBeenCalled() expect(vi.mocked(removeHint)).toHaveBeenCalled() diff --git a/protocol-designer/src/organisms/BlockingHintModal/index.tsx b/protocol-designer/src/organisms/BlockingHintModal/index.tsx index be33b06742f..22a6b5646a6 100644 --- a/protocol-designer/src/organisms/BlockingHintModal/index.tsx +++ b/protocol-designer/src/organisms/BlockingHintModal/index.tsx @@ -38,7 +38,6 @@ export function BlockingHintModal(props: HintProps): JSX.Element { }, []) const onCancelClick = (): void => { - dispatch(actions.removeHint(hintKey, rememberDismissal)) handleCancel() } @@ -56,7 +55,7 @@ export function BlockingHintModal(props: HintProps): JSX.Element { void handleContinue: () => void @@ -16,8 +16,8 @@ export interface HintProps { export const useBlockingHint = (args: HintProps): JSX.Element | null => { const { enabled, hintKey, handleCancel, handleContinue, content } = args - const isDismissed = useSelector(getDismissedHints).includes(hintKey) - + const dismissedHints = useSelector(getDismissedHints) + const isDismissed = hintKey != null && dismissedHints.includes(hintKey) if (isDismissed) { if (enabled) { handleContinue() @@ -25,7 +25,7 @@ export const useBlockingHint = (args: HintProps): JSX.Element | null => { return null } - if (!enabled) { + if (!enabled || hintKey == null) { return null } diff --git a/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx b/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx new file mode 100644 index 00000000000..291461da4f5 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx @@ -0,0 +1,77 @@ +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + Box, + COLORS, + OVERFLOW_HIDDEN, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + StyledText, +} from '@opentrons/components' +import BOTTOM_LAYER from '../../assets/images/tip_side_bottom_layer.svg' +import MID_LAYER from '../../assets/images/tip_side_mid_layer.svg' +import TOP_LAYER from '../../assets/images/tip_side_top_layer.svg' + +const WELL_HEIGHT_PIXELS = 71 +const WELL_WIDTH_PIXELS = 70 +const PIXEL_DECIMALS = 2 + +interface TipPositionAllVizProps { + mmFromBottom: number + xPosition: number + wellDepthMm: number + xWidthMm: number +} + +export function TipPositionSideView( + props: TipPositionAllVizProps +): JSX.Element { + const { mmFromBottom, xPosition, wellDepthMm, xWidthMm } = props + const { t } = useTranslation('application') + const fractionOfWellHeight = mmFromBottom / wellDepthMm + const pixelsFromBottom = + fractionOfWellHeight * WELL_HEIGHT_PIXELS - WELL_HEIGHT_PIXELS + const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS) + const bottomPx = wellDepthMm + ? roundedPixelsFromBottom * 2 + : mmFromBottom - WELL_HEIGHT_PIXELS + + const xPositionPixels = (WELL_WIDTH_PIXELS / xWidthMm) * xPosition + const roundedXPositionPixels = round(xPositionPixels, PIXEL_DECIMALS) + + return ( + + + + + {wellDepthMm !== null && ( + + + {round(wellDepthMm, 0)} + {t('units.millimeter')} + + + )} + {xWidthMm !== null && ( + + + {xWidthMm} + {t('units.millimeter')} + + + )} + + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx b/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx new file mode 100644 index 00000000000..3fa107264e2 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx @@ -0,0 +1,61 @@ +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + Box, + COLORS, + OVERFLOW_HIDDEN, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + StyledText, +} from '@opentrons/components' +import BOTTOM_LAYER from '../../assets/images/tip_top_bottom_layer.svg' +import MID_LAYER from '../../assets/images/tip_top_mid_layer.svg' +import TOP_LAYER from '../../assets/images/tip_top_top_layer.svg' + +const WELL_WIDTH_PIXELS = 70 +const PIXEL_DECIMALS = 2 + +interface TipPositionAllVizProps { + xPosition: number + xWidthMm: number + yPosition: number + yWidthMm: number +} + +export function TipPositionTopView(props: TipPositionAllVizProps): JSX.Element { + const { yPosition, xPosition, yWidthMm, xWidthMm } = props + const { t } = useTranslation('application') + + const xPx = (WELL_WIDTH_PIXELS / xWidthMm) * xPosition + const yPx = (WELL_WIDTH_PIXELS / yWidthMm) * yPosition + + const roundedXPx = round(xPx, PIXEL_DECIMALS) + const roundedYPx = round(yPx, PIXEL_DECIMALS) + const translateY = roundedYPx < 0 ? Math.abs(roundedYPx) : -roundedYPx + return ( + + + + + {xWidthMm !== null && ( + + + {xWidthMm} + {t('units.millimeter')} + + + )} + + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/TipPositionZOnlyView.tsx b/protocol-designer/src/organisms/TipPositionModal/TipPositionZOnlyView.tsx new file mode 100644 index 00000000000..3d7da97e75c --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/TipPositionZOnlyView.tsx @@ -0,0 +1,64 @@ +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + Box, + COLORS, + OVERFLOW_HIDDEN, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + StyledText, +} from '@opentrons/components' +import BOTTOM_LAYER from '../../assets/images/tip_side_bottom_layer.svg' +import MID_LAYER from '../../assets/images/tip_side_mid_layer.svg' +import TOP_LAYER from '../../assets/images/tip_side_top_layer.svg' + +const WELL_HEIGHT_PIXELS = 71 +const PIXEL_DECIMALS = 2 + +interface TipPositionZOnlyViewProps { + wellDepthMm: number + mmFromBottom?: number + mmFromTop?: number +} + +export function TipPositionZOnlyView( + props: TipPositionZOnlyViewProps +): JSX.Element { + const { mmFromBottom, mmFromTop, wellDepthMm } = props + const { t } = useTranslation('application') + const positionInTube = mmFromBottom ?? mmFromTop ?? 0 + const fractionOfWellHeight = positionInTube / wellDepthMm + const pixelsFromBottom = + fractionOfWellHeight * WELL_HEIGHT_PIXELS - + (mmFromBottom != null ? WELL_HEIGHT_PIXELS : 0) + const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS) + + const bottomPx = roundedPixelsFromBottom * 2 + + return ( + + + + + {wellDepthMm !== null && ( + + + {round(wellDepthMm, 0)} + {t('units.millimeter')} + + + )} + + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/ZTipPositionModal.tsx b/protocol-designer/src/organisms/TipPositionModal/ZTipPositionModal.tsx new file mode 100644 index 00000000000..266403b9c2c --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/ZTipPositionModal.tsx @@ -0,0 +1,211 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + Btn, + DIRECTION_COLUMN, + Flex, + InputField, + JUSTIFY_END, + JUSTIFY_SPACE_BETWEEN, + Modal, + PrimaryButton, + SPACING, + SecondaryButton, + StyledText, +} from '@opentrons/components' +import { DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP } from '../../constants' +import { getIsTouchTipField } from '../../form-types' +import { BUTTON_LINK_STYLE } from '../../atoms' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import * as utils from './utils' +import { TOO_MANY_DECIMALS } from './constants' +import { TipPositionZOnlyView } from './TipPositionZOnlyView' + +import type { StepFieldName } from '../../form-types' + +interface ZTipPositionModalProps { + closeModal: () => void + zValue: number | null + name: StepFieldName + updateValue: (val?: number | null) => void + wellDepthMm: number + isIndeterminate?: boolean +} + +export function ZTipPositionModal(props: ZTipPositionModalProps): JSX.Element { + const { + isIndeterminate, + name, + wellDepthMm, + zValue, + closeModal, + updateValue, + } = props + const { t } = useTranslation(['modal', 'button']) + + const isBlowout = name === 'blowout_z_offset' + const defaultMm = isBlowout + ? 0 + : utils.getDefaultMmFromBottom({ + name, + wellDepthMm, + }) + + const [value, setValue] = React.useState( + zValue !== null ? String(zValue) : null + ) + + // in this modal, pristinity hides the OUT_OF_BOUNDS error only. + const [isPristine, setPristine] = React.useState(true) + + const getMinMaxMmFromBottom = (): { + maxMmFromBottom: number + minMmFromBottom: number + } => { + if (getIsTouchTipField(name)) { + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), + } + } + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: 0, + } + } + const { maxMmFromBottom, minMmFromBottom } = getMinMaxMmFromBottom() + + // For blowout from the top of the well + const minFromTop = DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP + const maxFromTop = -wellDepthMm + + const minMm = isBlowout ? maxFromTop : minMmFromBottom + const maxMm = isBlowout ? minFromTop : maxMmFromBottom + + const errors = utils.getErrors({ + minMm, + maxMm, + value, + }) + const hasErrors = errors.length > 0 + const hasVisibleErrors = isPristine + ? errors.includes(TOO_MANY_DECIMALS) + : hasErrors + + const errorText = utils.getErrorText({ + errors, + minMm, + maxMm, + isPristine, + t, + }) + + const handleDone = (): void => { + if (!hasErrors) { + updateValue(value == null ? null : Number(value)) + closeModal() + } + } + + const handleCancel = (): void => { + closeModal() + } + + const handleChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/, '') + : String(newValueRaw) + + if (newValue === '.') { + setValue('0.') + } else if (newValue === '-0') { + setValue('0') + } else { + isBlowout + ? setValue(newValue) + : setValue(Number(newValue) >= 0 ? newValue : '0') + } + } + + const handleInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleChange(e.currentTarget.value) + setPristine(false) + } + + return createPortal( + + { + setValue(utils.roundValue(defaultMm, 'up').toString()) + }} + css={BUTTON_LINK_STYLE} + > + {t('shared:reset_to_default')} + + + + {t('shared:cancel')} + + + {t('shared:save')} + + + + } + > + + + + {t(`tip_position.body.${name}`)} + + + + + + + + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/__tests__/TipPositionModal.test.tsx b/protocol-designer/src/organisms/TipPositionModal/__tests__/TipPositionModal.test.tsx new file mode 100644 index 00000000000..7a3c871d709 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/__tests__/TipPositionModal.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { TipPositionSideView } from '../TipPositionSideView' +import { TipPositionModal } from '..' + +import type * as React from 'react' + +vi.mock('../TipPositionSideView') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockUpdateZSpec = vi.fn() +const mockUpdateXSpec = vi.fn() +const mockUpdateYSpec = vi.fn() + +describe('TipPositionModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + prefix: 'aspirate', + closeModal: vi.fn(), + wellDepthMm: 50, + wellXWidthMm: 10.3, + wellYWidthMm: 10.5, + isIndeterminate: false, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: null, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: 0, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 0, + updateValue: mockUpdateYSpec, + }, + }, + } + vi.mocked(TipPositionSideView).mockReturnValue( +
    mock TipPositionSideView
    + ) + }) + it('renders the modal text', () => { + render(props) + screen.getByText('Edit aspirate tip position') + fireEvent.click(screen.getByText('Cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('Save')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders the alert if the x/y position values are too close to the max/min for x value', () => { + props.specs.x.value = 9.7 + render(props) + screen.getByText( + 'Tip position is close to the edge of the well and may cause collisions.' + ) + }) + it('renders the alert if the x/y position values are too close to the max/min for y value', () => { + props.specs.y.value = -9.7 + render(props) + screen.getByText( + 'Tip position is close to the edge of the well and may cause collisions.' + ) + }) + it('renders the captions, and visual', () => { + render(props) + screen.getByText('X position') + screen.getByText('between -5.1 and 5.1') + screen.getByText('Y position') + screen.getByText('between -5.2 and 5.2') + screen.getByText('Z position') + screen.getByText('between 0 and 50') + screen.getByText('mock TipPositionSideView') + }) + it('renders a custom input field and clicks on it, calling the mock updates', () => { + render(props) + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3 } }) + const yInputField = screen.getAllByRole('textbox', { name: '' })[1] + fireEvent.change(yInputField, { target: { value: -2 } }) + const zInputField = screen.getAllByRole('textbox', { name: '' })[2] + fireEvent.change(zInputField, { target: { value: 10 } }) + fireEvent.click(screen.getByText('Save')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders custom input fields and displays error texts', () => { + props = { + ...props, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: 101, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: -500, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 10.7, + updateValue: mockUpdateYSpec, + }, + }, + } + render(props) + fireEvent.click(screen.getByText('Save')) + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3.55555 } }) + fireEvent.click(screen.getByText('Save')) + // display too many decimals error + screen.getByText('a max of 1 decimal place is allowed') + }) +}) diff --git a/protocol-designer/src/organisms/TipPositionModal/__tests__/ZTipPositionModal.test.tsx b/protocol-designer/src/organisms/TipPositionModal/__tests__/ZTipPositionModal.test.tsx new file mode 100644 index 00000000000..20e148af0de --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/__tests__/ZTipPositionModal.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { ZTipPositionModal } from '../ZTipPositionModal' +import { TipPositionZOnlyView } from '../TipPositionZOnlyView' +import type * as React from 'react' + +vi.mock('../TipPositionZOnlyView') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ZTipPositionModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + closeModal: vi.fn(), + zValue: -2, + updateValue: vi.fn(), + wellDepthMm: 30, + name: 'blowout_z_offset', + } + vi.mocked(TipPositionZOnlyView).mockReturnValue( +
    mock TipPositionZOnlyView
    + ) + }) + it('renders the text and radio buttons', () => { + render(props) + screen.getByText('Edit blowout position') + screen.getByText('Change where in the well the robot performs the blowout.') + fireEvent.click(screen.getByText('Cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('Save')) + expect(props.closeModal).toHaveBeenCalled() + expect(props.updateValue).toHaveBeenCalled() + }) + it('renders the custom option, caption, and visual', () => { + render(props) + expect(screen.getAllByRole('textbox', { name: '' })).toHaveLength(1) + screen.getByText('between -30 and 0') + screen.getByText('mock TipPositionZOnlyView') + }) +}) diff --git a/protocol-designer/src/organisms/TipPositionModal/constants.ts b/protocol-designer/src/organisms/TipPositionModal/constants.ts new file mode 100644 index 00000000000..07ff520d143 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/constants.ts @@ -0,0 +1,3 @@ +export const DECIMALS_ALLOWED = 1 +export const TOO_MANY_DECIMALS: 'TOO_MANY_DECIMALS' = 'TOO_MANY_DECIMALS' +export const PERCENT_RANGE_TO_SHOW_WARNING = 0.9 diff --git a/protocol-designer/src/organisms/TipPositionModal/index.tsx b/protocol-designer/src/organisms/TipPositionModal/index.tsx new file mode 100644 index 00000000000..9499d28b548 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/index.tsx @@ -0,0 +1,364 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + Modal, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + Btn, + JUSTIFY_END, + SecondaryButton, + PrimaryButton, + StyledText, + Banner, + InputField, + TYPOGRAPHY, +} from '@opentrons/components' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import { getIsTouchTipField } from '../../form-types' +import { BUTTON_LINK_STYLE } from '../../atoms' +import { TOO_MANY_DECIMALS, PERCENT_RANGE_TO_SHOW_WARNING } from './constants' +import * as utils from './utils' +import { TipPositionTopView } from './TipPositionTopView' +import { TipPositionSideView } from './TipPositionSideView' + +import type { StepFieldName } from '../../form-types' + +type Offset = 'x' | 'y' | 'z' +interface PositionSpec { + name: StepFieldName + value: number | null + updateValue: (val?: number | null) => void +} +export type PositionSpecs = Record + +interface TipPositionModalProps { + closeModal: () => void + specs: PositionSpecs + wellDepthMm: number + wellXWidthMm: number + wellYWidthMm: number + isIndeterminate?: boolean + prefix: 'aspirate' | 'dispense' | 'mix' +} + +export function TipPositionModal( + props: TipPositionModalProps +): JSX.Element | null { + const { + isIndeterminate, + specs, + wellDepthMm, + wellXWidthMm, + wellYWidthMm, + closeModal, + prefix, + } = props + const { t } = useTranslation([ + 'modal', + 'button', + 'tooltip', + 'shared', + 'application', + ]) + const [view, setView] = React.useState<'top' | 'side'>('side') + const zSpec = specs.z + const ySpec = specs.y + const xSpec = specs.x + + if (zSpec == null || xSpec == null || ySpec == null) { + console.error( + 'expected to find specs for one of the positions but could not' + ) + } + + const defaultMmFromBottom = utils.getDefaultMmFromBottom({ + name: zSpec.name, + wellDepthMm, + }) + + const [zValue, setZValue] = React.useState( + zSpec?.value == null ? String(defaultMmFromBottom) : String(zSpec?.value) + ) + const [yValue, setYValue] = React.useState( + ySpec?.value == null ? null : String(ySpec?.value) + ) + const [xValue, setXValue] = React.useState( + xSpec?.value == null ? null : String(xSpec?.value) + ) + + // in this modal, pristinity hides the OUT_OF_BOUNDS error only. + const [isPristine, setPristine] = React.useState(true) + const getMinMaxMmFromBottom = (): { + maxMmFromBottom: number + minMmFromBottom: number + } => { + if (getIsTouchTipField(zSpec?.name ?? '')) { + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), + } + } + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: 0, + } + } + + const { maxMmFromBottom, minMmFromBottom } = getMinMaxMmFromBottom() + const { minValue: yMinWidth, maxValue: yMaxWidth } = utils.getMinMaxWidth( + wellYWidthMm + ) + const { minValue: xMinWidth, maxValue: xMaxWidth } = utils.getMinMaxWidth( + wellXWidthMm + ) + + const createErrors = ( + value: string | null, + min: number, + max: number + ): utils.Error[] => { + return utils.getErrors({ minMm: min, maxMm: max, value }) + } + const zErrors = createErrors(zValue, minMmFromBottom, maxMmFromBottom) + const xErrors = createErrors(xValue, xMinWidth, xMaxWidth) + const yErrors = createErrors(yValue, yMinWidth, yMaxWidth) + + const hasErrors = + zErrors.length > 0 || xErrors.length > 0 || yErrors.length > 0 + const hasVisibleErrors = isPristine + ? zErrors.includes(TOO_MANY_DECIMALS) || + xErrors.includes(TOO_MANY_DECIMALS) || + yErrors.includes(TOO_MANY_DECIMALS) + : hasErrors + + const createErrorText = ( + errors: utils.Error[], + min: number, + max: number + ): string | null => { + return utils.getErrorText({ errors, minMm: min, maxMm: max, isPristine, t }) + } + + const roundedXMin = utils.roundValue(xMinWidth, 'up') + const roundedYMin = utils.roundValue(yMinWidth, 'up') + const roundedXMax = utils.roundValue(xMaxWidth, 'down') + const roundedYMax = utils.roundValue(yMaxWidth, 'down') + + const zErrorText = createErrorText(zErrors, minMmFromBottom, maxMmFromBottom) + const xErrorText = createErrorText(xErrors, roundedXMin, roundedXMax) + const yErrorText = createErrorText(yErrors, roundedYMin, roundedYMax) + + const handleDone = (): void => { + if (!hasErrors) { + zSpec?.updateValue(zValue === null ? null : Number(zValue)) + xSpec?.updateValue(xValue === null ? null : Number(xValue)) + ySpec?.updateValue(yValue === null ? null : Number(yValue)) + closeModal() + } + } + + const handleCancel = (): void => { + closeModal() + } + + const handleZChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^.0-9]/, '') + : String(newValueRaw) + + if (newValue === '.') { + setZValue('0.') + } else { + setZValue(Number(newValue) >= 0 ? newValue : '0') + } + } + + const handleZInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleZChange(e.currentTarget.value) + setPristine(false) + } + + const handleXChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setXValue('0.') + } else { + setXValue(newValue) + } + } + + const handleXInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleXChange(e.currentTarget.value) + setPristine(false) + } + + const handleYChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setYValue('0.') + } else { + setYValue(newValue) + } + } + + const handleYInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleYChange(e.currentTarget.value) + setPristine(false) + } + const isXValueNearEdge = + xValue != null && + (parseInt(xValue) > PERCENT_RANGE_TO_SHOW_WARNING * xMaxWidth || + parseInt(xValue) < PERCENT_RANGE_TO_SHOW_WARNING * xMinWidth) + const isYValueNearEdge = + yValue != null && + (parseInt(yValue) > PERCENT_RANGE_TO_SHOW_WARNING * yMaxWidth || + parseInt(yValue) < PERCENT_RANGE_TO_SHOW_WARNING * yMinWidth) + const isZValueAtBottom = zValue != null && zValue === '0' + + return createPortal( + + { + setXValue('0') + setYValue('0') + setZValue('1') + }} + css={BUTTON_LINK_STYLE} + > + {t('shared:reset_to_default')} + + + + {t('shared:cancel')} + + + {t('shared:save')} + + +
    + } + > + + {isXValueNearEdge || isYValueNearEdge || isZValueAtBottom ? ( + + + {t('tip_position.warning')} + + + ) : null} + + + + {t(`tip_position.body.${zSpec?.name}`)} + + + + + + + + + {view === 'side' ? 'Side view' : 'Top view'} + + { + setView(view === 'side' ? 'top' : 'side') + }} + > + {t('shared:swap_view')} + + + {view === 'side' ? ( + + ) : ( + + )} + + + + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/utils.tsx b/protocol-designer/src/organisms/TipPositionModal/utils.tsx new file mode 100644 index 00000000000..8c576c77f47 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/utils.tsx @@ -0,0 +1,120 @@ +import floor from 'lodash/floor' +import round from 'lodash/round' +import { getIsTouchTipField } from '../../form-types' +import { + DEFAULT_MM_FROM_BOTTOM_ASPIRATE, + DEFAULT_MM_FROM_BOTTOM_DISPENSE, + DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP, +} from '../../constants' +import { DECIMALS_ALLOWED, TOO_MANY_DECIMALS } from './constants' +import type { StepFieldName } from '../../form-types' + +export function getDefaultMmFromBottom(args: { + name: StepFieldName + wellDepthMm: number +}): number { + const { name, wellDepthMm } = args + + switch (name) { + case 'aspirate_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_ASPIRATE + + case 'aspirate_delay_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_ASPIRATE + + case 'dispense_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + case 'dispense_delay_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + case 'mix_mmFromBottom': + // TODO: Ian 2018-11-131 figure out what offset makes most sense for mix + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + default: + // touch tip fields + console.assert( + getIsTouchTipField(name), + `getDefaultMmFromBottom fn does not know what to do with field ${name}` + ) + return DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP + wellDepthMm + } +} + +export const roundValue = ( + value: number | string | null, + direction: 'up' | 'down' +): number => { + if (value === null) return 0 + + switch (direction) { + case 'up': { + return round(Number(value), DECIMALS_ALLOWED) + } + case 'down': { + return floor(Number(value), DECIMALS_ALLOWED) + } + } +} + +const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' +export type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS + +export const getErrorText = (args: { + errors: Error[] + maxMm: number + minMm: number + isPristine: boolean + t: any +}): string | null => { + const { errors, minMm, maxMm, isPristine, t } = args + + if (errors.includes(TOO_MANY_DECIMALS)) { + return t('tip_position.errors.TOO_MANY_DECIMALS') + } else if (!isPristine && errors.includes(OUT_OF_BOUNDS)) { + return t('tip_position.errors.OUT_OF_BOUNDS', { + minMm, + maxMm, + }) + } else { + return null + } +} + +export const getErrors = (args: { + value: string | null + maxMm: number + minMm: number +}): Error[] => { + const { value: rawValue, maxMm, minMm } = args + const errors: Error[] = [] + + const value = Number(rawValue) + if (rawValue === null || Number.isNaN(value)) { + // blank or otherwise invalid should show this error as a fallback + return [OUT_OF_BOUNDS] + } + const hasIncorrectDecimals = round(value, DECIMALS_ALLOWED) !== value + const isOutOfBounds = value > maxMm || value < minMm + + if (hasIncorrectDecimals) { + errors.push(TOO_MANY_DECIMALS) + } + if (isOutOfBounds) { + errors.push(OUT_OF_BOUNDS) + } + return errors +} + +interface MinMaxValues { + minValue: number + maxValue: number +} + +export const getMinMaxWidth = (width: number): MinMaxValues => { + return { + minValue: -width * 0.5, + maxValue: width * 0.5, + } +} diff --git a/protocol-designer/src/organisms/WellOrderModal/WellOrderVisualization.tsx b/protocol-designer/src/organisms/WellOrderModal/WellOrderVisualization.tsx new file mode 100644 index 00000000000..e73f68a174b --- /dev/null +++ b/protocol-designer/src/organisms/WellOrderModal/WellOrderVisualization.tsx @@ -0,0 +1,42 @@ +import b2t_l2r from '../../assets/images/well-order_b2t-l2r.jpg' +import b2t_r2l from '../../assets/images/well-order_b2t-r2l.jpg' +import l2r_b2t from '../../assets/images/well-order_l2r-b2t.jpg' +import l2r_t2b from '../../assets/images/well-order_l2r-t2b.jpg' +import r2l_b2t from '../../assets/images/well-order_r2l-b2t.jpg' +import r2l_t2b from '../../assets/images/well-order_r2l-t2b.jpg' +import t2b_l2r from '../../assets/images/well-order_t2b-l2r.jpg' +import t2b_r2l from '../../assets/images/well-order_t2b-r2l.jpg' + +import type { WellOrderOption } from '../../form-types' + +interface WellOrderVisualizationProps { + firstValue: WellOrderOption + secondValue: WellOrderOption +} + +const imageMap: Record = { + b2t_l2r, + b2t_r2l, + l2r_b2t, + l2r_t2b, + r2l_b2t, + r2l_t2b, + t2b_l2r, + t2b_r2l, +} + +export function WellOrderVisualization( + props: WellOrderVisualizationProps +): JSX.Element { + const { firstValue, secondValue } = props + const imageKey = `${firstValue}_${secondValue}` + + return ( + {`${firstValue} + ) +} diff --git a/protocol-designer/src/organisms/WellOrderModal/__tests__/WellOrderModal.test.tsx b/protocol-designer/src/organisms/WellOrderModal/__tests__/WellOrderModal.test.tsx new file mode 100644 index 00000000000..798bd8abc89 --- /dev/null +++ b/protocol-designer/src/organisms/WellOrderModal/__tests__/WellOrderModal.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../assets/localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { WellOrderModal } from '..' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('WellOrderModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + isOpen: true, + closeModal: vi.fn(), + prefix: 'aspirate', + firstName: 'aspirate_wellOrder_l', + secondName: 'aspirate_wellOrder_r', + firstValue: null, + secondValue: null, + updateValues: vi.fn(), + } + }) + it('renders all the text and buttons for the modal with the default fields', () => { + render(props) + screen.getByText('Edit well order') + screen.getByText('Change how the robot moves from well to well.') + screen.getByText('Primary order') + screen.getByText('then') + screen.getByText('Secondary order') + fireEvent.click(screen.getByText('Reset to default')) + expect(props.closeModal).toHaveBeenCalled() + expect(props.updateValues).toHaveBeenCalled() + fireEvent.click(screen.getByText('Cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('Save')) + expect(props.closeModal).toHaveBeenCalled() + expect(props.updateValues).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/organisms/WellOrderModal/index.tsx b/protocol-designer/src/organisms/WellOrderModal/index.tsx new file mode 100644 index 00000000000..929bc534460 --- /dev/null +++ b/protocol-designer/src/organisms/WellOrderModal/index.tsx @@ -0,0 +1,228 @@ +import { createPortal } from 'react-dom' +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { + Modal, + Flex, + Btn, + JUSTIFY_SPACE_BETWEEN, + SecondaryButton, + PrimaryButton, + SPACING, + StyledText, + DIRECTION_COLUMN, + DropdownMenu, + ALIGN_CENTER, +} from '@opentrons/components' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import { BUTTON_LINK_STYLE } from '../../atoms' +import { WellOrderVisualization } from './WellOrderVisualization' +import type { WellOrderOption } from '../../form-types' + +const DEFAULT_FIRST: WellOrderOption = 't2b' +const DEFAULT_SECOND: WellOrderOption = 'l2r' +const VERTICAL_VALUES: WellOrderOption[] = ['t2b', 'b2t'] +const HORIZONTAL_VALUES: WellOrderOption[] = ['l2r', 'r2l'] +const WELL_ORDER_VALUES: WellOrderOption[] = [ + ...VERTICAL_VALUES, + ...HORIZONTAL_VALUES, +] + +export interface WellOrderModalProps { + isOpen: boolean + closeModal: () => void + prefix: 'aspirate' | 'dispense' | 'mix' + firstName: string + secondName: string + firstValue?: WellOrderOption | null + secondValue?: WellOrderOption | null + updateValues: ( + firstValue?: WellOrderOption | null, + secondValue?: WellOrderOption | null + ) => void +} + +interface State { + firstValue: WellOrderOption + secondValue: WellOrderOption +} + +export function WellOrderModal(props: WellOrderModalProps): JSX.Element | null { + const { t } = useTranslation(['form', 'modal', 'shared']) + const { + isOpen, + closeModal, + updateValues, + firstValue, + secondValue, + firstName, + secondName, + } = props + const getInitialFirstValues = (): { + initialFirstValue: WellOrderOption + initialSecondValue: WellOrderOption + } => { + if (firstValue == null || secondValue == null) { + return { + initialFirstValue: DEFAULT_FIRST, + initialSecondValue: DEFAULT_SECOND, + } + } + return { + initialFirstValue: firstValue, + initialSecondValue: secondValue, + } + } + const { initialFirstValue, initialSecondValue } = getInitialFirstValues() + + const [wellOrder, setWellOrder] = useState({ + firstValue: initialFirstValue, + secondValue: initialSecondValue, + }) + + useEffect(() => { + setWellOrder({ + firstValue: initialFirstValue, + secondValue: initialSecondValue, + }) + }, [initialFirstValue, initialSecondValue]) + + const applyChanges = (): void => { + updateValues(wellOrder.firstValue, wellOrder.secondValue) + } + + const handleReset = (): void => { + setWellOrder({ firstValue: DEFAULT_FIRST, secondValue: DEFAULT_SECOND }) + applyChanges() + closeModal() + } + + const handleCancel = (): void => { + const { initialFirstValue, initialSecondValue } = getInitialFirstValues() + setWellOrder({ + firstValue: initialFirstValue, + secondValue: initialSecondValue, + }) + closeModal() + } + + const handleDone = (): void => { + applyChanges() + closeModal() + } + + const makeOnChange = (ordinality: 'first' | 'second') => ( + value: string + ): void => { + let nextState: State = { ...wellOrder, [`${ordinality}Value`]: value } + + if (ordinality === 'first') { + if ( + VERTICAL_VALUES.includes(value as WellOrderOption) && + VERTICAL_VALUES.includes(wellOrder.secondValue) + ) { + nextState = { ...nextState, secondValue: HORIZONTAL_VALUES[0] } + } else if ( + HORIZONTAL_VALUES.includes(value as WellOrderOption) && + HORIZONTAL_VALUES.includes(wellOrder.secondValue) + ) { + nextState = { ...nextState, secondValue: VERTICAL_VALUES[0] } + } + } + setWellOrder(nextState) + } + + const isSecondOptionDisabled = (value: WellOrderOption): boolean => { + if (VERTICAL_VALUES.includes(wellOrder.firstValue)) { + return VERTICAL_VALUES.includes(value) + } else if (HORIZONTAL_VALUES.includes(wellOrder.firstValue)) { + return HORIZONTAL_VALUES.includes(value) + } else { + return false + } + } + + if (!isOpen) return null + + return createPortal( + + + {t('shared:reset_to_default')} + + + + {t('shared:cancel')} + + + {t('shared:save')} + + + + } + > + + + + + {t('shared:change_robot_movement')} + + ({ + value, + name: t(`step_edit_form.field.well_order.option.${value}`), + }))} + /> + + + + {t('modal:well_order.then')} + + ({ + value, + name: t(`step_edit_form.field.well_order.option.${value}`), + disabled: isSecondOptionDisabled(value), + }))} + /> + + + + + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index 0fd6e481e3e..3d6fee9b662 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -16,3 +16,6 @@ export * from './ProtocolMetadataNav' export * from './SelectWellsModal' export * from './SlotDetailsContainer' export * from './SlotInformation' +export * from './TipPositionModal' +export * from './TipPositionModal/ZTipPositionModal' +export * from './WellOrderModal' diff --git a/protocol-designer/src/pages/Designer/DeckSetup/HoveredItems.tsx b/protocol-designer/src/pages/Designer/DeckSetup/HoveredItems.tsx index d2497cb200a..79bba166f88 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/HoveredItems.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/HoveredItems.tsx @@ -58,6 +58,10 @@ export const HoveredItems = ( hoveredLabware != null ? defs[hoveredLabware] ?? customLabwareDefs[hoveredLabware] ?? null : null + const selectedLabwareDef = + selectedLabwareDefUri != null + ? defs[selectedLabwareDefUri] ?? customLabwareDefs[selectedLabwareDefUri] + : null const orientation = hoveredSlotPosition != null @@ -65,11 +69,11 @@ export const HoveredItems = ( : null const nestedInfo: DeckLabelProps[] = - selectedLabwareDefUri != null && + selectedLabwareDef != null && (hoveredLabware == null || hoveredLabware !== selectedLabwareDefUri) ? [ { - text: defs[selectedLabwareDefUri].metadata.displayName, + text: selectedLabwareDef.metadata.displayName, isLast: false, isSelected: true, }, diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx index 78686519452..258f5fe07d6 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx @@ -53,6 +53,16 @@ export const SelectedHoveredItems = ( hoveredLabware != null ? defs[hoveredLabware] ?? customLabwareDefs[hoveredLabware] ?? null : null + const selectedLabwareDef = + selectedLabwareDefUri != null + ? defs[selectedLabwareDefUri] ?? customLabwareDefs[selectedLabwareDefUri] + : null + const selectedNestedLabwareDef = + selectedNestedLabwareDefUri != null + ? defs[selectedNestedLabwareDefUri] ?? + customLabwareDefs[selectedNestedLabwareDefUri] + : null + const orientation = slotPosition != null ? inferModuleOrientationFromXCoordinate(slotPosition[0]) @@ -64,7 +74,8 @@ export const SelectedHoveredItems = ( selectedLabwareDefUri != null && (hoveredLabware == null || hoveredLabware !== selectedLabwareDefUri) ) { - const def = defs[selectedLabwareDefUri] + const def = + defs[selectedLabwareDefUri] ?? customLabwareDefs[selectedLabwareDefUri] const selectedLabwareLabel = { text: def.metadata.displayName, isSelected: true, @@ -72,10 +83,9 @@ export const SelectedHoveredItems = ( } labwareInfos.push(selectedLabwareLabel) } - if (selectedNestedLabwareDefUri != null && hoveredLabware == null) { - const def = defs[selectedNestedLabwareDefUri] + if (selectedNestedLabwareDef != null && hoveredLabware == null) { const selectedNestedLabwareLabel = { - text: def.metadata.displayName, + text: selectedNestedLabwareDef.metadata.displayName, isSelected: true, isLast: hoveredLabware == null, } @@ -122,20 +132,18 @@ export const SelectedHoveredItems = ( orientation={orientation} > <> - {selectedLabwareDefUri != null && + {selectedLabwareDef != null && selectedModuleModel != null && hoveredLabware == null ? ( - + ) : null} - {selectedNestedLabwareDefUri != null && + {selectedNestedLabwareDef != null && selectedModuleModel != null && hoveredLabware == null ? ( - + ) : null} {hoveredLabwareDef != null && selectedModuleModel != null ? ( @@ -157,41 +165,41 @@ export const SelectedHoveredItems = ( ) : null} ) : null} - {selectedLabwareDefUri != null && + {selectedLabwareDef != null && slotPosition != null && selectedModuleModel == null && hoveredLabware == null ? ( <> - + {selectedNestedLabwareDefUri == null ? ( ) : null} ) : null} - {selectedNestedLabwareDefUri != null && + {selectedNestedLabwareDef != null && slotPosition != null && selectedModuleModel == null && hoveredLabware == null ? ( <> - + - {selectedLabwareDefUri != null ? ( + {selectedLabwareDef != null ? ( (true) const pipetteEntities = useSelector(stepFormSelectors.getPipetteEntities) const pipette = pipetteId != null ? pipetteEntities[pipetteId] : null - const pipetteDisplayName = pipette ? pipette.spec.displayName : t('pipette') - const innerKey = `${name}:${String(value || 0)}` const matchingTipLiquidSpecs = pipette != null ? getMatchingTipLiquidSpecs(pipette, volume as number, tiprack as string) : null - let defaultFlowRate + let defaultFlowRate = 0 if (pipette) { if (flowRateType === 'aspirate') { defaultFlowRate = @@ -46,18 +45,59 @@ export function FlowRateField(props: FlowRateFieldProps): JSX.Element { matchingTipLiquidSpecs?.defaultBlowOutFlowRate.default ?? 0 } } + + const title = i18n.format( + t('protocol_steps:flow_type_title', { type: flowRateType }), + 'capitalize' + ) + + const flowRateNum = Number(passThruProps.value) + const maxFlowRate = matchingTipLiquidSpecs?.uiMaxFlowRate ?? Infinity + + const outOfBounds = flowRateNum > maxFlowRate || flowRateNum < 0 + + let errorMessage: string | null = null + if ( + (!isPristine && passThruProps.value !== undefined && flowRateNum === 0) || + outOfBounds + ) { + errorMessage = i18n.format( + t('step_edit_form.field.flow_rate.error_out_of_bounds', { + min: 0.1, + max: maxFlowRate, + }), + 'capitalize' + ) + } + + useEffect(() => { + if (isPristine && errorMessage != null) { + passThruProps.updateValue(defaultFlowRate) + } + }, []) + return ( - ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx deleted file mode 100644 index 210f831bb86..00000000000 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { useState } from 'react' -import { createPortal } from 'react-dom' -import round from 'lodash/round' -import { useTranslation } from 'react-i18next' -import { - RadioGroup, - Flex, - useHoverTooltip, - InputField, - Modal, - SecondaryButton, - PrimaryButton, - Tooltip, -} from '@opentrons/components' -import { getMainPagePortalEl } from '../../../../../components/portals/MainPageModalPortal' -import type { ChangeEvent } from 'react' -import type { FieldProps } from '../types' - -const DECIMALS_ALLOWED = 1 - -export interface FlowRateInputProps extends FieldProps { - flowRateType: 'aspirate' | 'dispense' | 'blowout' - minFlowRate: number - maxFlowRate: number - defaultFlowRate?: number | null - pipetteDisplayName?: string | null -} - -interface InitialState { - isPristine: boolean - modalUseDefault: boolean - showModal: boolean - modalFlowRate?: string | null -} - -export function FlowRateInput(props: FlowRateInputProps): JSX.Element { - const { - defaultFlowRate, - disabled, - flowRateType, - isIndeterminate, - maxFlowRate, - minFlowRate, - name, - pipetteDisplayName, - tooltipContent, - value, - } = props - const [targetProps, tooltipProps] = useHoverTooltip() - const { t, i18n } = useTranslation([ - 'form', - 'application', - 'shared', - 'protocol_steps', - ]) - - const initialState: InitialState = { - isPristine: true, - modalFlowRate: props.value ? String(props.value) : null, - modalUseDefault: !props.value && !isIndeterminate, - showModal: false, - } - - const [isPristine, setIsPristine] = useState( - initialState.isPristine - ) - - const [modalFlowRate, setModalFlowRate] = useState< - InitialState['modalFlowRate'] - >(initialState.modalFlowRate) - - const [modalUseDefault, setModalUseDefault] = useState< - InitialState['modalUseDefault'] - >(initialState.modalUseDefault) - - const [showModal, setShowModal] = useState( - initialState.showModal - ) - - const resetModalState = (): void => { - setShowModal(initialState.showModal) - setModalFlowRate(initialState.modalFlowRate) - setModalUseDefault(initialState.modalUseDefault) - setIsPristine(initialState.isPristine) - } - - const cancelModal = resetModalState - - const openModal = (): void => { - setShowModal(true) - } - - const makeSaveModal = (allowSave: boolean) => (): void => { - setIsPristine(false) - - if (allowSave) { - const newFlowRate = modalUseDefault ? null : Number(modalFlowRate) - props.updateValue(newFlowRate) - resetModalState() - } - } - - const handleChangeRadio = (e: ChangeEvent): void => { - setModalUseDefault(e.target.value !== 'custom') - } - - const handleChangeNumber = (e: ChangeEvent): void => { - const value = e.target.value - if (value === '' || value === '.' || !Number.isNaN(Number(value))) { - setModalFlowRate(value) - setModalUseDefault(false) - } - } - const title = i18n.format( - t('protocol_steps:flow_type_title', { type: flowRateType }), - 'capitalize' - ) - - const modalFlowRateNum = Number(modalFlowRate) - - // show 0.1 not 0 as minimum, since bottom of range is non-inclusive - const displayMinFlowRate = minFlowRate || Math.pow(10, -DECIMALS_ALLOWED) - const rangeDescription = t('step_edit_form.field.flow_rate.range', { - min: displayMinFlowRate, - max: maxFlowRate, - }) - const outOfBounds = - modalFlowRateNum === 0 || - minFlowRate > modalFlowRateNum || - modalFlowRateNum > maxFlowRate - const correctDecimals = - round(modalFlowRateNum, DECIMALS_ALLOWED) === modalFlowRateNum - const allowSave = modalUseDefault || (!outOfBounds && correctDecimals) - - let errorMessage = null - // validation only happens when "Custom" is selected, not "Default" - // and pristinity only masks the outOfBounds error, not the correctDecimals error - if (!modalUseDefault) { - if (!Number.isNaN(modalFlowRateNum) && !correctDecimals) { - errorMessage = t('step_edit_form.field.flow_rate.error_decimals', { - decimals: `${DECIMALS_ALLOWED}`, - }) - } else if (!isPristine && outOfBounds) { - errorMessage = t('step_edit_form.field.flow_rate.error_out_of_bounds', { - min: displayMinFlowRate, - max: maxFlowRate, - }) - } - } - - const FlowRateInputField = ( - - ) - - // TODO: update the modal - const FlowRateModal = - pipetteDisplayName && - createPortal( - - - {t('shared:cancel')} - - - {t('shared:done')} - - - } - > -

    {t('protocol_steps:flow_type_title', { type: flowRateType })}

    - -
    {title}
    - -
    {`${flowRateType} speed`}
    - - -
    , - getMainPagePortalEl() - ) - - return ( - <> - {flowRateType === 'blowout' ? ( - - - {tooltipContent} - - ) : ( - - - - )} - - {showModal && FlowRateModal} - - ) -} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx index 5023dca0a3f..01aee01f1a7 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx @@ -13,18 +13,17 @@ import { useHoverTooltip, } from '@opentrons/components' import { getWellsDepth, getWellDimension } from '@opentrons/shared-data' +import { TipPositionModal, ZTipPositionModal } from '../../../../../organisms' import { getIsDelayPositionField } from '../../../../../form-types' +import { getDefaultMmFromBottom } from '../../../../../organisms/TipPositionModal/utils' import { selectors as stepFormSelectors } from '../../../../../step-forms' -import { TipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' -import { getDefaultMmFromBottom } from '../../../../../components/StepEditForm/fields/TipPositionField/utils' -import { ZTipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/ZTipPositionModal' import type { TipXOffsetFields, TipYOffsetFields, TipZOffsetFields, } from '../../../../../form-types' +import type { PositionSpecs } from '../../../../../organisms' import type { FieldPropsByName } from '../types' -import type { PositionSpecs } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' interface PositionFieldProps { prefix: 'aspirate' | 'dispense' | 'mix' propsForFields: FieldPropsByName @@ -91,6 +90,7 @@ export function PositionField(props: PositionFieldProps): JSX.Element { } const isDelayPositionField = getIsDelayPositionField(zName) let zValue: string | number = '0' + const mmFromBottom = typeof rawZValue === 'number' ? rawZValue : null if (wellDepthMm !== null) { // show default value for field in parens if no mmFromBottom value is selected @@ -145,6 +145,7 @@ export function PositionField(props: PositionFieldProps): JSX.Element { wellYWidthMm={wellYWidthMm} isIndeterminate={isIndeterminate} specs={specs} + prefix={prefix} /> ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx index a4152db7d4b..742fcbaf0ea 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx @@ -10,7 +10,7 @@ import { DIRECTION_COLUMN, COLORS, } from '@opentrons/components' -import { WellOrderModal } from '../../../../../components/StepEditForm/fields/WellOrderField/WellOrderModal' +import { WellOrderModal } from '../../../../../organisms' import type { WellOrderOption } from '../../../../../form-types' import type { FieldProps } from '../types' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index d18bee31915..634bb422ecb 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -244,9 +244,11 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } > - {showFormErrorsAndWarnings ? ( - - ) : null} + diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx index 0d1213db9f0..6a0315bb3bf 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx @@ -16,10 +16,17 @@ import { ToggleExpandStepFormField, ToggleStepFormField, } from '../../../../../../molecules' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' import type { StepFormProps } from '../../types' export function HeaterShakerTools(props: StepFormProps): JSX.Element { - const { propsForFields, formData } = props + const { + propsForFields, + formData, + showFormErrors = false, + focusedField = null, + visibleFormErrors, + } = props const { t } = useTranslation(['application', 'form', 'protocol_steps']) const moduleLabwareOptions = useSelector(getHeaterShakerLabwareOptions) @@ -29,6 +36,8 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element { } }, []) + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + return ( {moduleLabwareOptions.length > 1 ? ( @@ -82,6 +91,12 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element { offLabel={t( 'form:step_edit_form.field.heaterShaker.temperature.toggleOff' )} + formLevelError={getFormLevelError( + showFormErrors, + 'targetHeaterShakerTemperature', + mappedErrorsToField, + focusedField + )} /> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx index 893a2b4d5f1..a319afc572a 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx @@ -150,6 +150,7 @@ export function MixTools(props: StepFormProps): JSX.Element { - error.dependentFields.includes('aspirate_labware') + error.dependentFields.includes('aspirate_wells') ) ?? false } /> @@ -208,6 +208,7 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { @@ -88,6 +97,7 @@ export function PauseTools(props: StepFormProps): JSX.Element { ) => { propsForFields.pauseAction.updateValue(e.currentTarget.value) + setShowFormErrorsAndWarnings?.(false) }} buttonLabel={t( 'form:step_edit_form.field.pauseAction.options.untilResume' @@ -101,6 +111,7 @@ export function PauseTools(props: StepFormProps): JSX.Element { ) => { propsForFields.pauseAction.updateValue(e.currentTarget.value) + setShowFormErrorsAndWarnings?.(false) }} buttonLabel={t( 'form:step_edit_form.field.pauseAction.options.untilTime' @@ -112,6 +123,7 @@ export function PauseTools(props: StepFormProps): JSX.Element { ) => { propsForFields.pauseAction.updateValue(e.currentTarget.value) + setShowFormErrorsAndWarnings?.(false) }} buttonLabel={t( 'form:step_edit_form.field.pauseAction.options.untilTemperature' @@ -146,6 +158,12 @@ export function PauseTools(props: StepFormProps): JSX.Element { units={t('application:units.time_hms')} padding="0" showTooltip={false} + formLevelError={getFormLevelError( + showFormErrors, + 'pauseTime', + mappedErrorsToField, + focusedField + )} /> @@ -184,6 +202,12 @@ export function PauseTools(props: StepFormProps): JSX.Element { errorToShow={propsForFields.pauseTemperature.errorToShow} padding="0" showTooltip={false} + formLevelError={getFormLevelError( + showFormErrors, + 'pauseTemperature', + mappedErrorsToField, + focusedField + )} /> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx index f31f77f00c4..59d94469fd1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx @@ -6,14 +6,27 @@ import { StyledText, } from '@opentrons/components' import { InputStepFormField } from '../../../../../../molecules' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' +import type { StepFormErrors } from '../../../../../../steplist' import type { FieldPropsByName } from '../../types' interface ProfileSettingsProps { propsForFields: FieldPropsByName + showFormErrors: boolean + visibleFormErrors: StepFormErrors + focusedField?: string | null } export function ProfileSettings(props: ProfileSettingsProps): JSX.Element { - const { propsForFields } = props + const { + propsForFields, + showFormErrors, + visibleFormErrors, + focusedField, + } = props + + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + const { i18n, t } = useTranslation(['application', 'form']) return ( ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx index a5f1676f2c8..0b00c140f65 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx @@ -10,8 +10,10 @@ import { ToggleExpandStepFormField, ToggleStepFormField, } from '../../../../../../molecules' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' import type { FormData } from '../../../../../../form-types' +import type { StepFormErrors } from '../../../../../../steplist' import type { FieldPropsByName } from '../../types' interface ThermocyclerStateProps { @@ -19,12 +21,25 @@ interface ThermocyclerStateProps { formData: FormData propsForFields: FieldPropsByName isHold?: boolean + visibleFormErrors: StepFormErrors + showFormErrors?: boolean + focusedField?: string | null } export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { - const { title, propsForFields, formData, isHold = false } = props + const { + title, + propsForFields, + formData, + isHold = false, + visibleFormErrors, + showFormErrors = true, + focusedField, + } = props const { i18n, t } = useTranslation(['application', 'form']) + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + const { blockFieldActive, lidFieldActive, @@ -68,6 +83,12 @@ export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { isSelected={formData[blockFieldActive] === true} onLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOn')} offLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOff')} + formLevelError={getFormLevelError( + showFormErrors, + blockTempField, + mappedErrorsToField, + focusedField + )} /> ( @@ -42,6 +50,7 @@ export function ThermocyclerTools(props: StepFormProps): JSX.Element { onChange={() => { setContentType('thermocyclerState') propsForFields.thermocyclerFormType.updateValue('thermocyclerState') + setShowFormErrorsAndWarnings?.(false) }} isSelected={contentType === 'thermocyclerState'} /> @@ -56,6 +65,7 @@ export function ThermocyclerTools(props: StepFormProps): JSX.Element { propsForFields.thermocyclerFormType.updateValue( 'thermocyclerProfile' ) + setShowFormErrorsAndWarnings?.(false) }} isSelected={contentType === 'thermocyclerProfile'} /> @@ -67,12 +77,20 @@ export function ThermocyclerTools(props: StepFormProps): JSX.Element { title={t('step_edit_form.field.thermocyclerState.state')} propsForFields={propsForFields} formData={formData} + showFormErrors={showFormErrors} + visibleFormErrors={visibleFormErrors} + focusedField={focusedField} /> ) } else { return ( - + ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx index c6cb5f13383..368bfb1d1ee 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx @@ -69,6 +69,7 @@ describe('MagnetTools', () => { value: 10, }, }, + showFormErrors: false, } vi.mocked(getMagneticLabwareOptions).mockReturnValue([ { name: 'mock labware in mock module in slot abc', value: 'mockValue' }, diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx index 8edd87af457..498e6b2e1db 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx @@ -71,6 +71,7 @@ describe('TemperatureTools', () => { value: null, }, }, + showFormErrors: false, } vi.mocked(getTemperatureModuleIds).mockReturnValue(['mockId']) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts index 3d3e3a47393..6007eae6d0c 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts @@ -6,8 +6,24 @@ import { import { capitalizeFirstLetter, getBlowoutLocationOptionsForForm, + getFormErrorsMappedToField, + getFormLevelError, } from '../utils' +const BASE_VISIBLE_FORM_ERROR = { + title: 'form level error title', + dependentFields: ['field1', 'field2'], +} + +const MAPPED_ERRORS = { + field1: { + title: 'form level error title', + dependentFields: ['field1', 'field2'], + showAtField: true, + showAtForm: true, + }, +} + describe('getBlowoutLocationOptionsForForm', () => { const destOption = { name: 'Destination Well', @@ -69,3 +85,63 @@ describe('capitalizeFirstLetter', () => { ) }) }) + +describe('getFormErrorsMappedToField', () => { + it('should flatten form-level errors to an object keyed by each implicated field with form-level error as the value', () => { + const result = getFormErrorsMappedToField([BASE_VISIBLE_FORM_ERROR]) + expect(result).toEqual({ + field1: { + ...BASE_VISIBLE_FORM_ERROR, + showAtField: true, + showAtForm: true, + }, + field2: { + ...BASE_VISIBLE_FORM_ERROR, + showAtField: true, + showAtForm: true, + }, + }) + }) + it('should maintain booleans for showAtForm and showAtField', () => { + const result = getFormErrorsMappedToField([ + { ...BASE_VISIBLE_FORM_ERROR, showAtForm: false }, + ]) + expect(result).toEqual({ + field1: { + ...BASE_VISIBLE_FORM_ERROR, + showAtField: true, + showAtForm: false, + }, + field2: { + ...BASE_VISIBLE_FORM_ERROR, + showAtField: true, + showAtForm: false, + }, + }) + }) +}) + +describe('getFormLevelError', () => { + it('shows form-level error at field when field is not focused and showAtField is true', () => { + const result = getFormLevelError(true, 'field1', MAPPED_ERRORS) + expect(result).toEqual('form level error title') + }) + + it('shows no form-level error at field when field is focused and showAtField is true', () => { + const result = getFormLevelError(true, 'field1', MAPPED_ERRORS, 'field1') + expect(result).toBeNull() + }) + + it('shows no form-level error at field when field is not focused and showAtField is false', () => { + const result = getFormLevelError( + true, + 'field1', + { + ...MAPPED_ERRORS, + field1: { ...MAPPED_ERRORS.field1, showAtField: false }, + }, + 'field2' + ) + expect(result).toBeNull() + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts index c7b063c4731..f0bd6970e73 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts @@ -26,4 +26,7 @@ export interface StepFormProps { propsForFields: FieldPropsByName toolboxStep: number visibleFormErrors: StepFormErrors + showFormErrors: boolean + focusedField?: string | null + setShowFormErrorsAndWarnings?: React.Dispatch> } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts index 00f4749a71b..563308f1238 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts @@ -25,6 +25,7 @@ import type { StepType, PathOption, } from '../../../../form-types' +import type { FormError } from '../../../../steplist/formLevel' import type { NozzleType } from '../../../../types' import type { FieldProps, FieldPropsByName, FocusHandlers } from './types' @@ -325,3 +326,42 @@ export const getSaveStepSnackbarText = ( export const capitalizeFirstLetter = (stepName: string): string => `${stepName.charAt(0).toUpperCase()}${stepName.slice(1)}` + +type ErrorMappedToField = Record + +export const getFormErrorsMappedToField = ( + formErrors: StepFormErrors +): ErrorMappedToField => { + return formErrors.reduce((acc, error) => { + const { dependentFields } = error + for (const field of dependentFields) { + const { showAtField, showAtForm, title } = error + if (showAtField == null || showAtForm == null) { + console.error( + `${title} should wire up where to show error (at form and/or field)` + ) + } + // map each field to only one one error + acc[field] = { + ...error, + showAtField: error.showAtField ?? true, + showAtForm: error.showAtForm ?? true, + } + } + return acc + }, {}) +} + +export const getFormLevelError = ( + showFormErrors: boolean, + fieldName: string, + mappedErrorsToField: ErrorMappedToField, + focusedField?: string | null +): string | null => { + return showFormErrors && + focusedField !== fieldName && + mappedErrorsToField[fieldName] && + mappedErrorsToField[fieldName].showAtField + ? mappedErrorsToField[fieldName].title + : null +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx index 7d76fc02ae2..3b231529243 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx @@ -148,6 +148,7 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { lidOpen, thermocyclerFormType, lidOpenHold, + blockTargetTempHold, profileTargetLidTemp, profileVolume, } = currentStep @@ -192,7 +193,7 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { @@ -215,9 +216,6 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { pauseTime, pauseTemperature, } = currentStep - const pauseModuleDisplayName = getModuleDisplayName( - modules[pauseModuleId].model - ) switch (pauseAction) { case 'untilResume': stepSummaryContent = ( @@ -227,6 +225,9 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { ) break case 'untilTemperature': + const pauseModuleDisplayName = getModuleDisplayName( + modules[pauseModuleId].model + ) stepSummaryContent = ( { if (hasTrashEntity) { navigate('/overview') + dispatch(selectTerminalItem('__initial_setup__')) } else { makeSnackbar(t('trash_required') as string) } diff --git a/protocol-designer/src/pages/ProtocolOverview/UnusedModalContent.tsx b/protocol-designer/src/pages/ProtocolOverview/UnusedModalContent.tsx index d53c2169b8c..279bf5abe9f 100644 --- a/protocol-designer/src/pages/ProtocolOverview/UnusedModalContent.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/UnusedModalContent.tsx @@ -1,7 +1,16 @@ import type * as React from 'react' -import { COLORS, Icon, StyledText } from '@opentrons/components' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + SPACING, + StyledText, +} from '@opentrons/components' +import { getModuleDisplayName } from '@opentrons/shared-data' import type { ModuleOnDeck, PipetteOnDeck } from '../../step-forms' +import type { HintKey } from '../../tutorial' import type { Fixture } from './index' interface MissingContent { @@ -11,12 +20,15 @@ interface MissingContent { gripperWithoutStep: boolean fixtureWithoutStep: Fixture t: any + enableRedesign?: boolean } -interface WarningContent { +// TODO (nd: 10/29/2024) refine interface once redesign FF is removed +export interface WarningContent { content: React.ReactNode - heading: string + heading?: string titleElement?: JSX.Element + hintKey?: HintKey } // TODO(ja): update this to use StyledText @@ -27,177 +39,250 @@ export function getWarningContent({ gripperWithoutStep, fixtureWithoutStep, t, + enableRedesign = true, }: MissingContent): WarningContent | null { - if (noCommands) { - return { - heading: t('alert:export_warnings.no_commands.redesign.heading'), - content: ( - - {t('alert:export_warnings.no_commands.redesign.body')} - - ), - titleElement: ( - - ), + if (enableRedesign) { + if (noCommands) { + return { + hintKey: 'no_commands', + content: ( + + {t('alert:export_warnings.redesign.no_commands.body1')} + + ), + } + } else if ( + pipettesWithoutStep.length + + modulesWithoutStep.length + + (gripperWithoutStep ? 1 : 0) + + fixtureWithoutStep.stagingAreaSlots.length + + (fixtureWithoutStep.trashBin ? 1 : 0) + + (fixtureWithoutStep.wasteChute ? 1 : 0) > + 1 + ) { + const allUnusedContent = [ + ...pipettesWithoutStep.map(pipette => + t('alert:export_warnings.redesign.unused_pipette', { + pipette: pipette.spec.displayName, + mount: pipette.mount, + }) + ), + ...(gripperWithoutStep + ? [t('modules:additional_equipment_display_names.gripper')] + : []), + ...modulesWithoutStep.map(module => + t('alert:export_warnings.redesign.unused_module', { + module: getModuleDisplayName(module.model), + slot: module.slot, + }) + ), + ...fixtureWithoutStep.stagingAreaSlots.map(slot => + t('alert:export_warnings.redesign.unused_staging_area', { slot }) + ), + ...(fixtureWithoutStep.trashBin + ? [t('modules:additional_equipment_display_names.trashBin')] + : []), + ...(fixtureWithoutStep.wasteChute + ? [t('modules:additional_equipment_display_names.wasteChute')] + : []), + ] + return { + hintKey: 'unused_hardware', + content: ( + + + {t( + 'alert:export_warnings.redesign.unused_hardware_content.body1' + )} + + + + {t('alert:export_warnings.redesign.unused_hardware')} + + {allUnusedContent.map((unusedHardware, i) => ( + + {unusedHardware} + + ))} + + + ), + } + } else { + return null } - } - - if (gripperWithoutStep) { - return { - content: ( - <> -

    {t('alert:export_warnings.unused_gripper.body1')}

    -

    {t('alert:export_warnings.unused_gripper.body2')}

    - - ), - heading: t('alert:export_warnings.unused_gripper.heading'), + } else { + if (noCommands) { + return { + heading: t('alert:export_warnings.no_commands.redesign.heading'), + content: ( + + {t('alert:export_warnings.no_commands.redesign.body')} + + ), + titleElement: ( + + ), + } } - } - - const pipettesDetails = pipettesWithoutStep - .map(pipette => - pipette.spec.channels === 96 - ? `${pipette.spec.displayName} pipette` - : `${pipette.mount} ${pipette.spec.displayName} pipette` - ) - .join(' and ') - const unusedModuleCounts = modulesWithoutStep.reduce<{ - [key: string]: number - }>((acc, mod) => { - if (!(mod.type in acc)) { - return { ...acc, [mod.type]: 1 } - } else { - return { ...acc, [mod.type]: acc[mod.type] + 1 } + if (gripperWithoutStep) { + return { + content: ( + <> +

    {t('alert:export_warnings.unused_gripper.body1')}

    +

    {t('alert:export_warnings.unused_gripper.body2')}

    + + ), + heading: t('alert:export_warnings.unused_gripper.heading'), + } } - }, {}) - const modulesDetails = Object.keys(unusedModuleCounts) - // sorting by module count - .sort((k1, k2) => { - if (unusedModuleCounts[k1] < unusedModuleCounts[k2]) { - return 1 - } else if (unusedModuleCounts[k1] > unusedModuleCounts[k2]) { - return -1 + const pipettesDetails = pipettesWithoutStep + .map(pipette => + pipette.spec.channels === 96 + ? `${pipette.spec.displayName} pipette` + : `${pipette.mount} ${pipette.spec.displayName} pipette` + ) + .join(' and ') + + const unusedModuleCounts = modulesWithoutStep.reduce<{ + [key: string]: number + }>((acc, mod) => { + if (!(mod.type in acc)) { + return { ...acc, [mod.type]: 1 } } else { - return 0 + return { ...acc, [mod.type]: acc[mod.type] + 1 } } - }) - .map(modType => - unusedModuleCounts[modType] === 1 - ? t(`modules:module_long_names.${modType}`) - : `${t(`modules:module_long_names.${modType}`)}s` - ) - // transform list of modules with counts to string - .reduce((acc, modName, index, arr) => { - if (arr.length > 2) { - if (index === arr.length - 1) { - return `${acc} and ${modName}` + }, {}) + + const modulesDetails = Object.keys(unusedModuleCounts) + // sorting by module count + .sort((k1, k2) => { + if (unusedModuleCounts[k1] < unusedModuleCounts[k2]) { + return 1 + } else if (unusedModuleCounts[k1] > unusedModuleCounts[k2]) { + return -1 } else { - return `${acc}${modName}, ` + return 0 } - } else if (arr.length === 2) { - return index === 0 ? `${modName} and ` : `${acc}${modName}` - } else { - return modName + }) + .map(modType => + unusedModuleCounts[modType] === 1 + ? t(`modules:module_long_names.${modType}`) + : `${t(`modules:module_long_names.${modType}`)}s` + ) + // transform list of modules with counts to string + .reduce((acc, modName, index, arr) => { + if (arr.length > 2) { + if (index === arr.length - 1) { + return `${acc} and ${modName}` + } else { + return `${acc}${modName}, ` + } + } else if (arr.length === 2) { + return index === 0 ? `${modName} and ` : `${acc}${modName}` + } else { + return modName + } + }, '') + + if (pipettesWithoutStep.length && modulesWithoutStep.length) { + return { + content: ( + <> +

    + {t('alert:export_warnings.unused_pipette_and_module.body1', { + modulesDetails, + pipettesDetails, + })} +

    +

    {t('alert:export_warnings.unused_pipette_and_module.body2')}

    + + ), + heading: t('alert:export_warnings.unused_pipette_and_module.heading'), } - }, '') + } - if (pipettesWithoutStep.length && modulesWithoutStep.length) { - return { - content: ( - <> -

    - {t('alert:export_warnings.unused_pipette_and_module.body1', { - modulesDetails, - pipettesDetails, - })} -

    -

    {t('alert:export_warnings.unused_pipette_and_module.body2')}

    - - ), - heading: t('alert:export_warnings.unused_pipette_and_module.heading'), + if (pipettesWithoutStep.length) { + return { + content: ( + <> +

    + {t('alert:export_warnings.unused_pipette.body1', { + pipettesDetails, + })} +

    +

    {t('alert:export_warnings.unused_pipette.body2')}

    + + ), + heading: t('alert:export_warnings.unused_pipette.heading'), + } } - } - if (pipettesWithoutStep.length) { - return { - content: ( - <> -

    - {t('alert:export_warnings.unused_pipette.body1', { - pipettesDetails, - })} -

    -

    {t('alert:export_warnings.unused_pipette.body2')}

    - - ), - heading: t('alert:export_warnings.unused_pipette.heading'), + if (modulesWithoutStep.length) { + const moduleCase = + modulesWithoutStep.length > 1 ? 'unused_modules' : 'unused_module' + const slotName = modulesWithoutStep.map(module => module.slot) + return { + content: ( + <> +

    + {t(`alert:export_warnings.${moduleCase}.body1`, { + modulesDetails, + slotName: slotName, + })} +

    +

    {t(`alert:export_warnings.${moduleCase}.body2`)}

    + + ), + heading: t(`alert:export_warnings.${moduleCase}.heading`), + } } - } - if (modulesWithoutStep.length) { - const moduleCase = - modulesWithoutStep.length > 1 ? 'unused_modules' : 'unused_module' - const slotName = modulesWithoutStep.map(module => module.slot) - return { - content: ( - <> -

    - {t(`alert:export_warnings.${moduleCase}.body1`, { - modulesDetails, - slotName: slotName, - })} -

    -

    {t(`alert:export_warnings.${moduleCase}.body2`)}

    - - ), - heading: t(`alert:export_warnings.${moduleCase}.heading`), + if (fixtureWithoutStep.trashBin || fixtureWithoutStep.wasteChute) { + return { + content: + (fixtureWithoutStep.trashBin && !fixtureWithoutStep.wasteChute) || + (!fixtureWithoutStep.trashBin && fixtureWithoutStep.wasteChute) ? ( +

    + {t('alert:export_warnings.unused_trash.body', { + name: fixtureWithoutStep.trashBin ? 'trash bin' : 'waste chute', + })} +

    + ) : ( +

    + {t('alert:export_warnings.unused_trash.body_both', { + trashName: 'trash bin', + wasteName: 'waste chute', + })} +

    + ), + heading: t('alert:export_warnings.unused_trash.heading'), + } } - } - if (fixtureWithoutStep.trashBin || fixtureWithoutStep.wasteChute) { - return { - content: - (fixtureWithoutStep.trashBin && !fixtureWithoutStep.wasteChute) || - (!fixtureWithoutStep.trashBin && fixtureWithoutStep.wasteChute) ? ( -

    - {t('alert:export_warnings.unused_trash.body', { - name: fixtureWithoutStep.trashBin ? 'trash bin' : 'waste chute', - })} -

    - ) : ( -

    - {t('alert:export_warnings.unused_trash.body_both', { - trashName: 'trash bin', - wasteName: 'waste chute', - })} -

    + if (fixtureWithoutStep.stagingAreaSlots.length) { + return { + content: ( + <> +

    + {t('alert:export_warnings.unused_staging_area.body1', { + count: fixtureWithoutStep.stagingAreaSlots.length, + slot: fixtureWithoutStep.stagingAreaSlots, + })} +

    +

    + {t('alert:export_warnings.unused_staging_area.body2', { + count: fixtureWithoutStep.stagingAreaSlots.length, + })} +

    + ), - heading: t('alert:export_warnings.unused_trash.heading'), + heading: t('alert:export_warnings.unused_staging_area.heading'), + } } - } - if (fixtureWithoutStep.stagingAreaSlots.length) { - return { - content: ( - <> -

    - {t('alert:export_warnings.unused_staging_area.body1', { - count: fixtureWithoutStep.stagingAreaSlots.length, - slot: fixtureWithoutStep.stagingAreaSlots, - })} -

    -

    - {t('alert:export_warnings.unused_staging_area.body2', { - count: fixtureWithoutStep.stagingAreaSlots.length, - })} -

    - - ), - heading: t('alert:export_warnings.unused_staging_area.heading'), - } + return null } - - return null } diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx index 50d9d48e7bd..9b4ec0e582f 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx @@ -10,6 +10,7 @@ import { getInitialDeckSetup, getSavedStepForms, } from '../../../step-forms/selectors' +import { getDismissedHints } from '../../../tutorial/selectors' import { MaterialsListModal } from '../../../organisms/MaterialsListModal' import { selectors as labwareIngredSelectors } from '../../../labware-ingred/selectors' import { ProtocolOverview } from '../index' @@ -25,6 +26,7 @@ import type { NavigateFunction } from 'react-router-dom' vi.mock('../OffdeckThumbnail') vi.mock('../DeckThumbnail') vi.mock('../../../step-forms/selectors') +vi.mock('../../../tutorial/selectors') vi.mock('../../../file-data/selectors') vi.mock('../../../organisms/MaterialsListModal') vi.mock('../../../labware-ingred/selectors') @@ -61,6 +63,7 @@ describe('ProtocolOverview', () => { vi.mocked(labwareIngredSelectors.allIngredientGroupFields).mockReturnValue( {} ) + vi.mocked(getDismissedHints).mockReturnValue([]) vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) vi.mocked(getInitialDeckSetup).mockReturnValue({ pipettes: {}, diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 4d68ba7e0d3..f622ef6e08f 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' import { format } from 'date-fns' import { css } from 'styled-components' -import { createPortal } from 'react-dom' import { ALIGN_CENTER, @@ -12,14 +11,10 @@ import { DIRECTION_COLUMN, EndUserAgreementFooter, Flex, - JUSTIFY_END, JUSTIFY_FLEX_END, JUSTIFY_SPACE_BETWEEN, LargeButton, - Modal, NO_WRAP, - PrimaryButton, - SecondaryButton, SPACING, StyledText, ToggleGroup, @@ -43,7 +38,7 @@ import { } from '../../components/FileSidebar/utils' import { MaterialsListModal } from '../../organisms/MaterialsListModal' import { BUTTON_LINK_STYLE, LINE_CLAMP_TEXT_STYLE } from '../../atoms' -import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import { useBlockingHint } from '../../organisms/BlockingHintModal/useBlockingHint' import { EditProtocolMetadataModal, EditInstrumentsModal, @@ -83,6 +78,7 @@ export function ProtocolOverview(): JSX.Element { 'alert', 'shared', 'starting_deck_State', + 'modules', ]) const navigate = useNavigate() const [ @@ -219,6 +215,19 @@ export function ProtocolOverview(): JSX.Element { setShowExportWarningModal(false) } + const confirmExport = (): void => { + setShowExportWarningModal(false) + dispatch(loadFileActions.saveProtocolFile()) + } + + const exportWarningModal = useBlockingHint({ + hintKey: warning?.hintKey ?? null, + enabled: showExportWarningModal && warning?.hintKey != null, + content: warning?.content, + handleCancel: cancelModal, + handleContinue: confirmExport, + }) + return ( {showEditMetadataModal ? ( @@ -235,37 +244,7 @@ export function ProtocolOverview(): JSX.Element { }} /> ) : null} - {showExportWarningModal && - createPortal( - - - {t('shared:cancel')} - - { - setShowExportWarningModal(false) - dispatch(loadFileActions.saveProtocolFile()) - }} - > - {t('alert:continue_with_export')} - -
    - } - > - {warning && warning.content} - , - getMainPagePortalEl() - )} + {exportWarningModal} {showMaterialsListModal ? ( { - if (hasWarning) { + if (warning != null) { setShowExportWarningModal(true) } else { dispatch(loadFileActions.saveProtocolFile()) diff --git a/protocol-designer/src/steplist/fieldLevel/index.ts b/protocol-designer/src/steplist/fieldLevel/index.ts index 96e04bfed07..5440cda40e7 100644 --- a/protocol-designer/src/steplist/fieldLevel/index.ts +++ b/protocol-designer/src/steplist/fieldLevel/index.ts @@ -417,6 +417,15 @@ const stepFieldHelperMap: Record = { tipRack: { getErrors: composeErrors(requiredField), }, + aspirate_flowRate: { + maskValue: composeMaskers(trimDecimals(1)), + }, + dispense_flowRate: { + maskValue: composeMaskers(trimDecimals(1)), + }, + mix_flowRate: { + maskValue: composeMaskers(trimDecimals(1)), + }, } const profileFieldHelperMap: Record = { // profile step fields diff --git a/protocol-designer/src/steplist/formLevel/errors.ts b/protocol-designer/src/steplist/formLevel/errors.ts index 9bf495b3134..95a2fdd2a3d 100644 --- a/protocol-designer/src/steplist/formLevel/errors.ts +++ b/protocol-designer/src/steplist/formLevel/errors.ts @@ -44,10 +44,17 @@ export type FormErrorKey = | 'PROFILE_LID_TEMPERATURE_REQUIRED' | 'BLOCK_TEMPERATURE_HOLD_REQUIRED' | 'LID_TEMPERATURE_HOLD_REQUIRED' + | 'PAUSE_TIME_REQUIRED' + | 'PAUSE_TEMP_REQUIRED' + | 'LABWARE_TO_MOVE_REQUIRED' + | 'NEW_LABWARE_LOCATION_REQUIRED' + export interface FormError { title: string body?: React.ReactNode dependentFields: StepFieldName[] + showAtField?: boolean + showAtForm?: boolean } const INCOMPATIBLE_ASPIRATE_LABWARE: FormError = { title: 'Selected aspirate labware is incompatible with pipette', @@ -112,38 +119,68 @@ const MODULE_ID_REQUIRED: FormError = { const TARGET_TEMPERATURE_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['setTemperature', 'targetTemperature'], + showAtForm: false, + showAtField: true, } const PROFILE_VOLUME_REQUIRED: FormError = { title: 'Volume is required', dependentFields: ['thermocyclerFormType', 'profileVolume'], + showAtForm: false, + showAtField: true, } const PROFILE_LID_TEMPERATURE_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['thermocyclerFormType', 'profileTargetLidTemp'], + showAtForm: false, + showAtField: true, } const LID_TEMPERATURE_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['lidIsActive', 'lidTargetTemp'], + showAtForm: false, + showAtField: true, } const BLOCK_TEMPERATURE_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['blockIsActive', 'blockTargetTemp'], + showAtForm: false, + showAtField: true, } const BLOCK_TEMPERATURE_HOLD_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['blockIsActiveHold', 'blockTargetTempHold'], + showAtForm: false, + showAtField: true, } const LID_TEMPERATURE_HOLD_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['lidIsActiveHold', 'lidTargetTempHold'], + showAtForm: false, + showAtField: true, } const SHAKE_SPEED_REQUIRED: FormError = { - title: 'Shake speed required', + title: 'Speed required', dependentFields: ['setShake', 'targetSpeed'], + showAtForm: false, + showAtField: true, } const SHAKE_TIME_REQUIRED: FormError = { - title: 'Shake duration required', + title: 'Duration required', dependentFields: ['heaterShakerSetTimer', 'heaterShakerTimer'], + showAtForm: false, + showAtField: true, +} +const PAUSE_TEMP_REQUIRED: FormError = { + title: 'Pause temperature required', + dependentFields: ['pauseTemperature', 'pauseAction'], + showAtForm: false, + showAtField: true, +} +const PAUSE_TIME_REQUIRED: FormError = { + title: 'Pause duration required', + dependentFields: ['pauseTime', 'pauseAction'], + showAtForm: false, + showAtField: true, } const HS_TEMPERATURE_REQUIRED: FormError = { title: 'Temperature required', @@ -151,6 +188,20 @@ const HS_TEMPERATURE_REQUIRED: FormError = { 'targetHeaterShakerTemperature', 'setHeaterShakerTemperature', ], + showAtForm: false, + showAtField: true, +} +const LABWARE_TO_MOVE_REQUIRED: FormError = { + title: 'Labware to move required', + dependentFields: ['labware'], + showAtForm: false, + showAtField: true, +} +const NEW_LABWARE_LOCATION_REQUIRED: FormError = { + title: 'New location required', + dependentFields: ['newLocation'], + showAtForm: false, + showAtField: true, } export interface HydratedFormData { @@ -384,6 +435,35 @@ export const temperatureRequired = ( ? HS_TEMPERATURE_REQUIRED : null } +export const pauseTimeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { pauseTime, pauseAction } = fields + return pauseAction === PAUSE_UNTIL_TIME && !pauseTime + ? PAUSE_TIME_REQUIRED + : null +} +export const pauseTemperatureRequired = ( + fields: HydratedFormData +): FormError | null => { + const { pauseTemperature, pauseAction } = fields + return pauseAction === PAUSE_UNTIL_TEMP && !pauseTemperature + ? PAUSE_TEMP_REQUIRED + : null +} +export const labwareToMoveRequired = ( + fields: HydratedFormData +): FormError | null => { + const { labware } = fields + return labware == null ? LABWARE_TO_MOVE_REQUIRED : null +} +export const newLabwareLocationRequired = ( + fields: HydratedFormData +): FormError | null => { + const { newLocation } = fields + console.log(fields) + return newLocation == null ? NEW_LABWARE_LOCATION_REQUIRED : null +} export const engageHeightRangeExceeded = ( fields: HydratedFormData ): FormError | null => { diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts index ee8556f7102..884a70da768 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts @@ -21,7 +21,8 @@ const updatePatchOnPauseTemperatureChange = ( 'pauseHour', 'pauseMinute', 'pauseSecond', - 'pauseTime' + 'pauseTime', + 'moduleId' ), } } diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index 1d67206c82b..dd7b9fea36c 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -3,7 +3,6 @@ import { incompatibleAspirateLabware, incompatibleDispenseLabware, incompatibleLabware, - pauseForTimeOrUntilTold, wellRatioMoveLiquid, magnetActionRequired, engageHeightRequired, @@ -20,6 +19,8 @@ import { shakeSpeedRequired, temperatureRequired, shakeTimeRequired, + pauseTimeRequired, + pauseTemperatureRequired, } from './errors' import { @@ -69,7 +70,7 @@ const stepFormHelperMap: Partial> = { ), }, pause: { - getErrors: composeErrors(pauseForTimeOrUntilTold), + getErrors: composeErrors(pauseTimeRequired, pauseTemperatureRequired), }, moveLiquid: { getErrors: composeErrors( diff --git a/protocol-designer/src/tutorial/index.ts b/protocol-designer/src/tutorial/index.ts index 99233e2d70f..b8223c92ed4 100644 --- a/protocol-designer/src/tutorial/index.ts +++ b/protocol-designer/src/tutorial/index.ts @@ -15,6 +15,8 @@ type HintKey = // normal hints | 'custom_labware_with_modules' | 'export_v8_1_protocol_7_3' | 'change_magnet_module_model' + | 'unused_hardware' + | 'no_commands' // DEPRECATED HINTS (keep a record to avoid name collisions with old persisted dismissal states) // 'export_v4_protocol' // | 'export_v4_protocol_3_18' diff --git a/react-api-client/src/runs/__tests__/useResumeFromRecoveryAssumingFalsePositiveMutation.test.tsx b/react-api-client/src/runs/__tests__/useResumeFromRecoveryAssumingFalsePositiveMutation.test.tsx new file mode 100644 index 00000000000..ec5900d4a62 --- /dev/null +++ b/react-api-client/src/runs/__tests__/useResumeFromRecoveryAssumingFalsePositiveMutation.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { act, renderHook, waitFor } from '@testing-library/react' + +import { createRunAction } from '@opentrons/api-client' + +import { useHost } from '../../api' +import { useResumeRunFromRecoveryAssumingFalsePositiveMutation } from '..' + +import { RUN_ID_1, mockResumeFromRecoveryAction } from '../__fixtures__' + +import type * as React from 'react' +import type { HostConfig, Response, RunAction } from '@opentrons/api-client' +import type { UseResumeRunFromRecoveryAssumingFalsePositiveMutationOptions } from '../useResumeFromRecoveryAssumingFalsePositiveMutation' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } + +describe('useResumeRunFromRecoveryAssumingFalsePositiveMutation hook', () => { + let wrapper: React.FunctionComponent< + { + children: React.ReactNode + } & UseResumeRunFromRecoveryAssumingFalsePositiveMutationOptions + > + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent< + { + children: React.ReactNode + } & UseResumeRunFromRecoveryAssumingFalsePositiveMutationOptions + > = ({ children }) => ( + {children} + ) + wrapper = clientProvider + }) + + it('should return no data when calling resumeRunFromRecoveryAssumingFalsePositive if the request fails', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createRunAction).mockRejectedValue('oh no') + + const { result } = renderHook( + useResumeRunFromRecoveryAssumingFalsePositiveMutation, + { + wrapper, + } + ) + + expect(result.current.data).toBeUndefined() + act(() => + result.current.resumeRunFromRecoveryAssumingFalsePositive(RUN_ID_1) + ) + await waitFor(() => { + expect(result.current.data).toBeUndefined() + }) + }) + + it('should create a resumeFromRecoveryAssumingFalsePositive run action when calling the resumeRunFromRecoveryAssumingFalsePositive callback', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createRunAction).mockResolvedValue({ + data: mockResumeFromRecoveryAction, + } as Response) + + const { result } = renderHook( + useResumeRunFromRecoveryAssumingFalsePositiveMutation, + { + wrapper, + } + ) + act(() => + result.current.resumeRunFromRecoveryAssumingFalsePositive(RUN_ID_1) + ) + + await waitFor(() => { + expect(result.current.data).toEqual(mockResumeFromRecoveryAction) + }) + }) +}) diff --git a/react-api-client/src/runs/__tests__/useResumeRunFromRecoveryMutation.test.tsx b/react-api-client/src/runs/__tests__/useResumeRunFromRecoveryMutation.test.tsx index cf9794c0dd8..f556b105153 100644 --- a/react-api-client/src/runs/__tests__/useResumeRunFromRecoveryMutation.test.tsx +++ b/react-api-client/src/runs/__tests__/useResumeRunFromRecoveryMutation.test.tsx @@ -9,22 +9,22 @@ import { useResumeRunFromRecoveryMutation } from '..' import { RUN_ID_1, mockResumeFromRecoveryAction } from '../__fixtures__' import type { HostConfig, Response, RunAction } from '@opentrons/api-client' -import type { UsePlayRunMutationOptions } from '../usePlayRunMutation' +import type { UseResumeRunFromRecoveryMutationOptions } from '../useResumeRunFromRecoveryMutation' vi.mock('@opentrons/api-client') vi.mock('../../api/useHost') const HOST_CONFIG: HostConfig = { hostname: 'localhost' } -describe('usePlayRunMutation hook', () => { +describe('useResumeRunFromRecoveryMutation hook', () => { let wrapper: React.FunctionComponent< - { children: React.ReactNode } & UsePlayRunMutationOptions + { children: React.ReactNode } & UseResumeRunFromRecoveryMutationOptions > beforeEach(() => { const queryClient = new QueryClient() const clientProvider: React.FunctionComponent< - { children: React.ReactNode } & UsePlayRunMutationOptions + { children: React.ReactNode } & UseResumeRunFromRecoveryMutationOptions > = ({ children }) => ( {children} ) diff --git a/react-api-client/src/runs/__tests__/useRunActionMutations.test.tsx b/react-api-client/src/runs/__tests__/useRunActionMutations.test.tsx index 7b40e4fc88e..e5c5c4cf265 100644 --- a/react-api-client/src/runs/__tests__/useRunActionMutations.test.tsx +++ b/react-api-client/src/runs/__tests__/useRunActionMutations.test.tsx @@ -10,18 +10,21 @@ import { usePauseRunMutation, useStopRunMutation, useResumeRunFromRecoveryMutation, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '..' import type { UsePlayRunMutationResult, UsePauseRunMutationResult, UseStopRunMutationResult, UseResumeRunFromRecoveryMutationResult, + UseResumeRunFromRecoveryAssumingFalsePositiveMutationResult, } from '..' vi.mock('../usePlayRunMutation') vi.mock('../usePauseRunMutation') vi.mock('../useStopRunMutation') vi.mock('../useResumeRunFromRecoveryMutation') +vi.mock('../useResumeFromRecoveryAssumingFalsePositiveMutation') describe('useRunActionMutations hook', () => { let wrapper: React.FunctionComponent<{ children: React.ReactNode }> @@ -44,6 +47,7 @@ describe('useRunActionMutations hook', () => { const mockPauseRun = vi.fn() const mockStopRun = vi.fn() const mockResumeRunFromRecovery = vi.fn() + const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn() vi.mocked(usePlayRunMutation).mockReturnValue(({ playRun: mockPlayRun, @@ -61,6 +65,12 @@ describe('useRunActionMutations hook', () => { resumeRunFromRecovery: mockResumeRunFromRecovery, } as unknown) as UseResumeRunFromRecoveryMutationResult) + vi.mocked( + useResumeRunFromRecoveryAssumingFalsePositiveMutation + ).mockReturnValue(({ + resumeRunFromRecoveryAssumingFalsePositive: mockResumeRunFromRecoveryAssumingFalsePositive, + } as unknown) as UseResumeRunFromRecoveryAssumingFalsePositiveMutationResult) + const { result } = renderHook(() => useRunActionMutations(RUN_ID_1), { wrapper, }) @@ -77,5 +87,12 @@ describe('useRunActionMutations hook', () => { act(() => result.current.resumeRunFromRecovery()) expect(mockResumeRunFromRecovery).toHaveBeenCalledTimes(1) expect(mockResumeRunFromRecovery).toHaveBeenCalledWith(RUN_ID_1) + act(() => result.current.resumeRunFromRecoveryAssumingFalsePositive()) + expect( + mockResumeRunFromRecoveryAssumingFalsePositive + ).toHaveBeenCalledTimes(1) + expect(mockResumeRunFromRecoveryAssumingFalsePositive).toHaveBeenCalledWith( + RUN_ID_1 + ) }) }) diff --git a/react-api-client/src/runs/index.ts b/react-api-client/src/runs/index.ts index 71e3360a5f9..5e479ed5093 100644 --- a/react-api-client/src/runs/index.ts +++ b/react-api-client/src/runs/index.ts @@ -10,6 +10,7 @@ export { usePlayRunMutation } from './usePlayRunMutation' export { usePauseRunMutation } from './usePauseRunMutation' export { useStopRunMutation } from './useStopRunMutation' export { useResumeRunFromRecoveryMutation } from './useResumeRunFromRecoveryMutation' +export { useResumeRunFromRecoveryAssumingFalsePositiveMutation } from './useResumeFromRecoveryAssumingFalsePositiveMutation' export { useRunActionMutations } from './useRunActionMutations' export { useAllCommandsQuery } from './useAllCommandsQuery' export { useAllCommandsAsPreSerializedList } from './useAllCommandsAsPreSerializedList' @@ -23,3 +24,4 @@ export type { UsePlayRunMutationResult } from './usePlayRunMutation' export type { UsePauseRunMutationResult } from './usePauseRunMutation' export type { UseStopRunMutationResult } from './useStopRunMutation' export type { UseResumeRunFromRecoveryMutationResult } from './useResumeRunFromRecoveryMutation' +export type { UseResumeRunFromRecoveryAssumingFalsePositiveMutationResult } from './useResumeFromRecoveryAssumingFalsePositiveMutation' diff --git a/react-api-client/src/runs/useResumeFromRecoveryAssumingFalsePositiveMutation.ts b/react-api-client/src/runs/useResumeFromRecoveryAssumingFalsePositiveMutation.ts new file mode 100644 index 00000000000..6eb10990053 --- /dev/null +++ b/react-api-client/src/runs/useResumeFromRecoveryAssumingFalsePositiveMutation.ts @@ -0,0 +1,60 @@ +import { useMutation } from 'react-query' + +import { + RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE, + createRunAction, +} from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { + UseMutateFunction, + UseMutationOptions, + UseMutationResult, +} from 'react-query' +import type { HostConfig, RunAction } from '@opentrons/api-client' + +export type UseResumeRunFromRecoveryAssumingFalsePositiveMutationResult = UseMutationResult< + RunAction, + AxiosError, + string +> & { + resumeRunFromRecoveryAssumingFalsePositive: UseMutateFunction< + RunAction, + AxiosError, + string + > +} + +export type UseResumeRunFromRecoveryAssumingFalsePositiveMutationOptions = UseMutationOptions< + RunAction, + AxiosError, + string +> + +export const useResumeRunFromRecoveryAssumingFalsePositiveMutation = ( + options: UseResumeRunFromRecoveryAssumingFalsePositiveMutationOptions = {} +): UseResumeRunFromRecoveryAssumingFalsePositiveMutationResult => { + const host = useHost() + const mutation = useMutation( + [ + host, + 'runs', + RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE, + ], + (runId: string) => + createRunAction(host as HostConfig, runId, { + actionType: RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE, + }) + .then(response => response.data) + .catch(e => { + throw e + }), + options + ) + return { + ...mutation, + resumeRunFromRecoveryAssumingFalsePositive: mutation.mutate, + } +} diff --git a/react-api-client/src/runs/useRunActionMutations.ts b/react-api-client/src/runs/useRunActionMutations.ts index 8bf3a08f1cb..a64411e7209 100644 --- a/react-api-client/src/runs/useRunActionMutations.ts +++ b/react-api-client/src/runs/useRunActionMutations.ts @@ -5,6 +5,7 @@ import { usePauseRunMutation, useStopRunMutation, useResumeRunFromRecoveryMutation, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '..' interface UseRunActionMutations { @@ -12,10 +13,12 @@ interface UseRunActionMutations { pauseRun: () => void stopRun: () => void resumeRunFromRecovery: () => void + resumeRunFromRecoveryAssumingFalsePositive: () => void isPlayRunActionLoading: boolean isPauseRunActionLoading: boolean isStopRunActionLoading: boolean isResumeRunFromRecoveryActionLoading: boolean + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading: boolean } export function useRunActionMutations(runId: string): UseRunActionMutations { @@ -43,6 +46,11 @@ export function useRunActionMutations(runId: string): UseRunActionMutations { isLoading: isResumeRunFromRecoveryActionLoading, } = useResumeRunFromRecoveryMutation() + const { + resumeRunFromRecoveryAssumingFalsePositive, + isLoading: isResumeRunFromRecoveryAssumingFalsePositiveActionLoading, + } = useResumeRunFromRecoveryAssumingFalsePositiveMutation() + return { playRun: () => { playRun(runId) @@ -56,9 +64,13 @@ export function useRunActionMutations(runId: string): UseRunActionMutations { resumeRunFromRecovery: () => { resumeRunFromRecovery(runId) }, + resumeRunFromRecoveryAssumingFalsePositive: () => { + resumeRunFromRecoveryAssumingFalsePositive(runId) + }, isPlayRunActionLoading, isPauseRunActionLoading, isStopRunActionLoading, isResumeRunFromRecoveryActionLoading, + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading, } } diff --git a/robot-server/robot_server/data_files/data_files_store.py b/robot-server/robot_server/data_files/data_files_store.py index a209dfc8e3a..28257dbb8d2 100644 --- a/robot-server/robot_server/data_files/data_files_store.py +++ b/robot-server/robot_server/data_files/data_files_store.py @@ -15,8 +15,9 @@ analysis_csv_rtp_table, run_csv_rtp_table, ) +from robot_server.persistence.tables.schema_7 import DataFileSourceSQLEnum -from .models import FileIdNotFoundError, FileInUseError +from .models import DataFileSource, FileIdNotFoundError, FileInUseError @dataclass(frozen=True) @@ -27,6 +28,7 @@ class DataFileInfo: name: str file_hash: str created_at: datetime + source: DataFileSource class DataFilesStore: @@ -53,6 +55,7 @@ async def insert(self, file_info: DataFileInfo) -> None: file_info_dict = { "id": file_info.id, "name": file_info.name, + "source": DataFileSourceSQLEnum(file_info.source.value), "created_at": file_info.created_at, "file_hash": file_info.file_hash, } @@ -80,14 +83,24 @@ def sql_get_all_from_engine(self) -> List[DataFileInfo]: all_rows = transaction.execute(statement).all() return [_convert_row_data_file_info(sql_row) for sql_row in all_rows] - def get_usage_info(self) -> List[FileUsageInfo]: + def get_usage_info( + self, source: Optional[DataFileSource] = None + ) -> List[FileUsageInfo]: """Return information about usage of all the existing data files in runs & analyses. Results are ordered with the oldest-added data file first. """ - select_all_data_file_ids = sqlalchemy.select(data_files_table.c.id).order_by( - sqlite_rowid - ) + if source is None: + select_all_data_file_ids = sqlalchemy.select( + data_files_table.c.id + ).order_by(sqlite_rowid) + else: + select_all_data_file_ids = ( + sqlalchemy.select(data_files_table.c.id) + .where(data_files_table.c.source.name == source.name) + .order_by(sqlite_rowid) + ) + select_ids_used_in_analyses = sqlalchemy.select( analysis_csv_rtp_table.c.file_id ).where(analysis_csv_rtp_table.c.file_id.is_not(None)) @@ -165,6 +178,7 @@ def _convert_row_data_file_info(row: sqlalchemy.engine.Row) -> DataFileInfo: return DataFileInfo( id=row.id, name=row.name, + source=DataFileSource(row.source.value), created_at=row.created_at, file_hash=row.file_hash, ) diff --git a/robot-server/robot_server/data_files/file_auto_deleter.py b/robot-server/robot_server/data_files/file_auto_deleter.py index 46c26eb866a..7f6c1e06493 100644 --- a/robot-server/robot_server/data_files/file_auto_deleter.py +++ b/robot-server/robot_server/data_files/file_auto_deleter.py @@ -1,14 +1,15 @@ -"""Auto-delete old data files to make room for new ones.""" +"""Auto-delete old user data files to make room for new ones.""" from logging import getLogger from robot_server.data_files.data_files_store import DataFilesStore +from robot_server.data_files.models import DataFileSource from robot_server.deletion_planner import DataFileDeletionPlanner _log = getLogger(__name__) class DataFileAutoDeleter: - """Auto deleter for data files.""" + """Auto deleter for uploaded data files.""" def __init__( self, @@ -22,9 +23,9 @@ async def make_room_for_new_file(self) -> None: """Delete old data files to make room for a new one.""" # It feels wasteful to collect usage info of upto 50 files # even when there's no need for deletion - data_file_usage_info = [ - usage_info for usage_info in self._data_files_store.get_usage_info() - ] + data_file_usage_info = self._data_files_store.get_usage_info( + DataFileSource.UPLOADED + ) if len(data_file_usage_info) < self._deletion_planner.maximum_allowed_files: return diff --git a/robot-server/robot_server/data_files/models.py b/robot-server/robot_server/data_files/models.py index f2da43bb0f6..5c279df6cc9 100644 --- a/robot-server/robot_server/data_files/models.py +++ b/robot-server/robot_server/data_files/models.py @@ -1,6 +1,7 @@ """Data files models.""" from datetime import datetime from typing import Literal, Set +from enum import Enum from pydantic import Field @@ -10,12 +11,24 @@ from robot_server.service.json_api import ResourceModel +class DataFileSource(Enum): + """The source this data file is from.""" + + UPLOADED = "uploaded" + GENERATED = "generated" + + class DataFile(ResourceModel): - """A model representing an uploaded data file.""" + """A model representing a data file.""" id: str = Field(..., description="A unique identifier for this file.") - name: str = Field(..., description="Name of the uploaded file.") - createdAt: datetime = Field(..., description="When this data file was *uploaded*.") + name: str = Field(..., description="Name of the data file.") + source: DataFileSource = Field( + ..., description="The origin of the file (uploaded or generated)" + ) + createdAt: datetime = Field( + ..., description="When this data file was uploaded or generated.." + ) class FileIdNotFoundError(GeneralError): diff --git a/robot-server/robot_server/data_files/router.py b/robot-server/robot_server/data_files/router.py index 39f191da553..cf4ba9fa649 100644 --- a/robot-server/robot_server/data_files/router.py +++ b/robot-server/robot_server/data_files/router.py @@ -22,7 +22,13 @@ ) from .data_files_store import DataFilesStore, DataFileInfo from .file_auto_deleter import DataFileAutoDeleter -from .models import DataFile, FileIdNotFoundError, FileIdNotFound, FileInUseError +from .models import ( + DataFile, + DataFileSource, + FileIdNotFoundError, + FileIdNotFound, + FileInUseError, +) from ..protocols.dependencies import get_file_hasher, get_file_reader_writer from ..service.dependencies import get_current_time, get_unique_id @@ -137,6 +143,7 @@ async def upload_data_file( id=existing_file_info.id, name=existing_file_info.name, createdAt=existing_file_info.created_at, + source=existing_file_info.source, ) ), status_code=status.HTTP_200_OK, @@ -151,6 +158,7 @@ async def upload_data_file( name=buffered_file.name, file_hash=file_hash, created_at=created_at, + source=DataFileSource.UPLOADED, ) await data_files_store.insert(file_info) return await PydanticResponse.create( @@ -159,6 +167,7 @@ async def upload_data_file( id=file_info.id, name=file_info.name, createdAt=created_at, + source=DataFileSource.UPLOADED, ) ), status_code=status.HTTP_201_CREATED, @@ -195,6 +204,7 @@ async def get_data_file_info_by_id( id=resource.id, name=resource.name, createdAt=resource.created_at, + source=resource.source, ) ), status_code=status.HTTP_200_OK, @@ -260,6 +270,7 @@ async def get_all_data_files( id=data_file_info.id, name=data_file_info.name, createdAt=data_file_info.created_at, + source=data_file_info.source, ) for data_file_info in data_files ], @@ -282,7 +293,7 @@ async def delete_file_by_id( dataFileId: str, data_files_store: DataFilesStore = Depends(get_data_files_store), ) -> PydanticResponse[SimpleEmptyBody]: - """Delete an uploaded data file by ID. + """Delete an uploaded or generated data file by ID. Arguments: dataFileId: ID of the data file to delete, pulled from URL. diff --git a/robot-server/robot_server/file_provider/fastapi_dependencies.py b/robot-server/robot_server/file_provider/fastapi_dependencies.py index 65042288f9a..7847e59b236 100644 --- a/robot-server/robot_server/file_provider/fastapi_dependencies.py +++ b/robot-server/robot_server/file_provider/fastapi_dependencies.py @@ -30,7 +30,7 @@ async def get_file_provider( FileProviderWrapper, fastapi.Depends(get_file_provider_wrapper) ], ) -> FileProvider: - """Return theengine `FileProvider` which accepts callbacks from FileProviderWrapper.""" + """Return the engine `FileProvider` which accepts callbacks from FileProviderWrapper.""" file_provider = FileProvider( data_files_write_csv_callback=file_provider_wrapper.write_csv_callback, data_files_filecount=file_provider_wrapper.csv_filecount_callback, diff --git a/robot-server/robot_server/file_provider/provider.py b/robot-server/robot_server/file_provider/provider.py index 5cfeb640fef..bbf05b40485 100644 --- a/robot-server/robot_server/file_provider/provider.py +++ b/robot-server/robot_server/file_provider/provider.py @@ -9,8 +9,12 @@ get_data_files_directory, get_data_files_store, ) +from robot_server.data_files.models import DataFileSource from ..service.dependencies import get_current_time, get_unique_id -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) from opentrons.protocol_engine.resources.file_provider import GenericCsvTransform @@ -62,13 +66,14 @@ async def write_csv_callback( name=csv_data.filename, file_hash="", created_at=created_at, + source=DataFileSource.GENERATED, ) await self._data_files_store.insert(file_info) return file_id async def csv_filecount_callback(self) -> int: - """Return the current count of files stored within the data files directory.""" - data_file_usage_info = [ - usage_info for usage_info in self._data_files_store.get_usage_info() - ] + """Return the current count of generated files stored within the data files directory.""" + data_file_usage_info = self._data_files_store.get_usage_info( + DataFileSource.GENERATED + ) return len(data_file_usage_info) diff --git a/robot-server/robot_server/persistence/_migrations/v6_to_v7.py b/robot-server/robot_server/persistence/_migrations/v6_to_v7.py index faae646e5b7..ce528a12ab7 100644 --- a/robot-server/robot_server/persistence/_migrations/v6_to_v7.py +++ b/robot-server/robot_server/persistence/_migrations/v6_to_v7.py @@ -3,6 +3,7 @@ Summary of changes from schema 6: - Adds a new command_intent to store the commands intent in the commands table +- Adds a new source to store the data files origin in the data_files table - Adds the `boolean_setting` table. """ @@ -15,7 +16,7 @@ import sqlalchemy from ..database import sql_engine_ctx, sqlite_rowid -from ..tables import schema_7 +from ..tables import DataFileSourceSQLEnum, schema_7 from .._folder_migrator import Migration from ..file_and_directory_names import ( @@ -35,7 +36,7 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: dest_db_file = dest_dir / DB_FILE - # Append the new column to existing protocols in v4 database + # Append the new column to existing protocols and data_files in v6 database with ExitStack() as exit_stack: dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) @@ -59,10 +60,20 @@ def add_column( schema_7.run_command_table.c.command_intent, ) + add_column( + dest_engine, + schema_7.data_files_table.name, + schema_7.data_files_table.c.source, + ) + _migrate_command_table_with_new_command_intent_col( dest_transaction=dest_transaction ) + _migrate_data_files_table_with_new_source_col( + dest_transaction=dest_transaction + ) + def _migrate_command_table_with_new_command_intent_col( dest_transaction: sqlalchemy.engine.Connection, @@ -83,3 +94,22 @@ def _migrate_command_table_with_new_command_intent_col( dest_transaction.execute( f"UPDATE run_command SET command_intent='{new_command_intent}' WHERE row_id={row.row_id}" ) + + +def _migrate_data_files_table_with_new_source_col( + dest_transaction: sqlalchemy.engine.Connection, +) -> None: + """Add a new 'source' column to data_files table.""" + select_data_files = sqlalchemy.select(schema_7.data_files_table).order_by( + sqlite_rowid + ) + insert_new_data = sqlalchemy.insert(schema_7.data_files_table) + for old_row in dest_transaction.execute(select_data_files).all(): + dest_transaction.execute( + insert_new_data, + id=old_row.id, + name=old_row.name, + file_hash=old_row.file_hash, + created_at=old_row.created_at, + source=DataFileSourceSQLEnum.UPLOADED, + ) diff --git a/robot-server/robot_server/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index 006f5356d76..fa0129a4ee6 100644 --- a/robot-server/robot_server/persistence/tables/__init__.py +++ b/robot-server/robot_server/persistence/tables/__init__.py @@ -16,6 +16,7 @@ PrimitiveParamSQLEnum, ProtocolKindSQLEnum, BooleanSettingKey, + DataFileSourceSQLEnum, ) @@ -34,4 +35,5 @@ "PrimitiveParamSQLEnum", "ProtocolKindSQLEnum", "BooleanSettingKey", + "DataFileSourceSQLEnum", ] diff --git a/robot-server/robot_server/persistence/tables/schema_7.py b/robot-server/robot_server/persistence/tables/schema_7.py index cf1e2d8d717..1690298007f 100644 --- a/robot-server/robot_server/persistence/tables/schema_7.py +++ b/robot-server/robot_server/persistence/tables/schema_7.py @@ -1,4 +1,4 @@ -"""v6 of our SQLite schema.""" +"""v7 of our SQLite schema.""" import enum import sqlalchemy @@ -23,6 +23,13 @@ class ProtocolKindSQLEnum(enum.Enum): QUICK_TRANSFER = "quick-transfer" +class DataFileSourceSQLEnum(enum.Enum): + """The source this data file is from.""" + + UPLOADED = "uploaded" + GENERATED = "generated" + + protocol_table = sqlalchemy.Table( "protocol", metadata, @@ -238,6 +245,17 @@ class ProtocolKindSQLEnum(enum.Enum): UTCDateTime, nullable=False, ), + sqlalchemy.Column( + "source", + sqlalchemy.Enum( + DataFileSourceSQLEnum, + values_callable=lambda obj: [e.value for e in obj], + validate_strings=True, + create_constraint=True, + ), + index=True, + nullable=False, + ), ) run_csv_rtp_table = sqlalchemy.Table( diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index a3a4a954961..ee769b47c01 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -15,7 +15,7 @@ from opentrons.protocols.parse import PythonParseMode from opentrons.protocol_reader import ProtocolReader, ProtocolSource -from robot_server.data_files.models import DataFile +from robot_server.data_files.models import DataFile, DataFileSource from robot_server.persistence.database import sqlite_rowid from robot_server.persistence.tables import ( analysis_table, @@ -346,6 +346,7 @@ async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: id=sql_row.id, name=sql_row.name, createdAt=sql_row.created_at, + source=DataFileSource(sql_row.source.value), ) for sql_row in data_files_rows ] diff --git a/robot-server/robot_server/runs/error_recovery_mapping.py b/robot-server/robot_server/runs/error_recovery_mapping.py index b548394cd8a..52da8caaad8 100644 --- a/robot-server/robot_server/runs/error_recovery_mapping.py +++ b/robot-server/robot_server/runs/error_recovery_mapping.py @@ -101,11 +101,9 @@ def _rule_matches_error( def _map_error_recovery_type(reaction_if_match: ReactionIfMatch) -> ErrorRecoveryType: match reaction_if_match: case ReactionIfMatch.IGNORE_AND_CONTINUE: - return ErrorRecoveryType.IGNORE_AND_CONTINUE + return ErrorRecoveryType.CONTINUE_WITH_ERROR case ReactionIfMatch.ASSUME_FALSE_POSITIVE_AND_CONTINUE: - # todo(mm, 2024-10-23): Connect to work in - # https://github.com/Opentrons/opentrons/pull/16556. - return ErrorRecoveryType.IGNORE_AND_CONTINUE + return ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE case ReactionIfMatch.FAIL_RUN: return ErrorRecoveryType.FAIL_RUN case ReactionIfMatch.WAIT_FOR_RECOVERY: diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index c108fa60a74..b7df09f8992 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -12,7 +12,13 @@ from pydantic import BaseModel, Field from opentrons_shared_data.errors import ErrorCodes -from opentrons.protocol_engine.types import CSVRuntimeParamPaths +from opentrons_shared_data.robot.types import RobotTypeEnum +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.modules.absorbance_reader import AbsorbanceReader +from opentrons.hardware_control.types import EstopState +from opentrons.protocol_engine.commands.absorbance_reader import CloseLid, OpenLid +from opentrons.protocol_engine.commands.move_labware import MoveLabware +from opentrons.protocol_engine.types import CSVRuntimeParamPaths, DeckSlotLocation from opentrons.protocol_engine import ( errors as pe_errors, ) @@ -27,6 +33,7 @@ from robot_server.protocols.protocol_models import ProtocolKind from robot_server.service.dependencies import get_current_time, get_unique_id from robot_server.robot.control.dependencies import require_estop_in_good_state +from robot_server.hardware import get_hardware, get_robot_type_enum from robot_server.service.json_api import ( RequestModel, @@ -48,6 +55,7 @@ from robot_server.protocols.router import ProtocolNotFound from ..run_models import ( + PlaceLabwareState, RunNotFoundError, ActiveNozzleLayout, RunCurrentState, @@ -567,17 +575,22 @@ async def get_run_commands_error( status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, }, ) -async def get_current_state( +async def get_current_state( # noqa: C901 runId: str, run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)], + hardware: Annotated[HardwareControlAPI, Depends(get_hardware)], + robot_type: Annotated[RobotTypeEnum, Depends(get_robot_type_enum)], ) -> PydanticResponse[Body[RunCurrentState, CurrentStateLinks]]: """Get current state associated with a run if the run is current. Arguments: runId: Run ID pulled from URL. run_data_manager: Run data retrieval interface. + hardware: Hardware control interface. + robot_type: The type of robot. """ try: + run = run_data_manager.get(run_id=runId) active_nozzle_maps = run_data_manager.get_nozzle_maps(run_id=runId) nozzle_layouts = { @@ -589,6 +602,7 @@ async def get_current_state( for pipetteId, nozzle_map in active_nozzle_maps.items() } + current_command = run_data_manager.get_current_command(run_id=runId) last_completed_command = run_data_manager.get_last_completed_command( run_id=runId ) @@ -604,9 +618,60 @@ async def get_current_state( else None ) + estop_engaged = False + place_labware = None + if robot_type == RobotTypeEnum.FLEX: + estop_engaged = hardware.get_estop_state() in [ + EstopState.PHYSICALLY_ENGAGED, + EstopState.LOGICALLY_ENGAGED, + ] + + command = ( + run_data_manager.get_command(runId, current_command.command_id) + if current_command + else None + ) + + # Labware state when estop is engaged + if isinstance(command, MoveLabware): + location = command.params.newLocation + if isinstance(location, DeckSlotLocation): + place_labware = PlaceLabwareState( + location=location, + labwareId=command.params.labwareId, + shouldPlaceDown=False, + ) + # Handle absorbance reader lid + elif isinstance(command, (OpenLid, CloseLid)): + for mod in run.modules: + if ( + not isinstance(mod, AbsorbanceReader) + and mod.id != command.params.moduleId + ): + continue + for hw_mod in hardware.attached_modules: + if ( + mod.location is not None + and hw_mod.serial_number == mod.serialNumber + ): + location = mod.location + labware_id = f"{mod.model}Lid{location.slotName}" + place_labware = PlaceLabwareState( + location=location, + labwareId=labware_id, + shouldPlaceDown=estop_engaged, + ) + break + if place_labware: + break + return await PydanticResponse.create( content=Body.construct( - data=RunCurrentState.construct(activeNozzleLayouts=nozzle_layouts), + data=RunCurrentState.construct( + estopEngaged=estop_engaged, + activeNozzleLayouts=nozzle_layouts, + placeLabwareState=place_labware, + ), links=links, ), status_code=status.HTTP_200_OK, diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 1619cd20a08..41252d4dfc3 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -95,15 +95,22 @@ def create_action( self._task_runner.run(self._run_orchestrator_store.stop) elif action_type == RunActionType.RESUME_FROM_RECOVERY: - self._run_orchestrator_store.resume_from_recovery() + log.info(f'Resuming run "{self._run_id}" from error recovery mode.') + self._run_orchestrator_store.resume_from_recovery( + reconcile_false_positive=False + ) elif ( action_type == RunActionType.RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE ): - # todo(mm, 2024-10-23): Connect to work in - # https://github.com/Opentrons/opentrons/pull/16556. - self._run_orchestrator_store.resume_from_recovery() + log.info( + f'Resuming run "{self._run_id}" from error recovery mode,' + f" assuming false-positive." + ) + self._run_orchestrator_store.resume_from_recovery( + reconcile_false_positive=True + ) else: assert_never(action_type) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index bac7c4c740c..8baedb97a3b 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -21,6 +21,7 @@ CommandNote, ) from opentrons.protocol_engine.types import ( + OnDeckLabwareLocation, RunTimeParameter, PrimitiveRunTimeParamValuesType, CSVRunTimeParamFilesType, @@ -315,10 +316,24 @@ class ActiveNozzleLayout(BaseModel): ) +class PlaceLabwareState(BaseModel): + """Details the labware being placed by the gripper.""" + + labwareId: str = Field(..., description="The ID of the labware to place.") + location: OnDeckLabwareLocation = Field( + ..., description="The location the labware should be in." + ) + shouldPlaceDown: bool = Field( + ..., description="Whether the gripper should place down the labware." + ) + + class RunCurrentState(BaseModel): """Current details about a run.""" - activeNozzleLayouts: Dict[str, ActiveNozzleLayout] = Field(..., description="") + estopEngaged: bool = Field(..., description="") + activeNozzleLayouts: Dict[str, ActiveNozzleLayout] = Field(...) + placeLabwareState: Optional[PlaceLabwareState] = Field(None) class CommandLinkNoMeta(BaseModel): diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index efa97347ae9..ee82ea034ac 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -207,6 +207,7 @@ async def create( run_id: The run resource the run orchestrator is assigned to. labware_offsets: Labware offsets to create the run with. deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. + file_provider: Wrapper to let the engine read/write data files. notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. run_time_param_values: Any runtime parameter values to set. @@ -310,9 +311,9 @@ async def stop(self) -> None: """Stop the run.""" await self.run_orchestrator.stop() - def resume_from_recovery(self) -> None: + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: """Resume the run from recovery mode.""" - self.run_orchestrator.resume_from_recovery() + self.run_orchestrator.resume_from_recovery(reconcile_false_positive) async def finish(self, error: Optional[Exception]) -> None: """Finish the run.""" diff --git a/robot-server/robot_server/service/notifications/publisher_notifier.py b/robot-server/robot_server/service/notifications/publisher_notifier.py index 89a53e27b64..4701aa83718 100644 --- a/robot-server/robot_server/service/notifications/publisher_notifier.py +++ b/robot-server/robot_server/service/notifications/publisher_notifier.py @@ -51,6 +51,8 @@ async def _wait_for_event(self) -> None: LOG.exception( f'PublisherNotifier: exception in callback {getattr(callback, "__name__", "")}' ) + except asyncio.exceptions.CancelledError: + LOG.warning("PublisherNotifuer task cancelled.") except BaseException: LOG.exception("PublisherNotifer notify task failed") diff --git a/robot-server/robot_server/settings.py b/robot-server/robot_server/settings.py index d4508406510..40b0ed663bb 100644 --- a/robot-server/robot_server/settings.py +++ b/robot-server/robot_server/settings.py @@ -107,7 +107,7 @@ class RobotServerSettings(BaseSettings): default=50, gt=0, description=( - "The maximum number of data files to allow before auto-deleting old ones." + "The maximum number of uploaded data files to allow before auto-deleting old ones." ), ) diff --git a/robot-server/tests/data_files/test_data_files_store.py b/robot-server/tests/data_files/test_data_files_store.py index caef1599961..581577d0a16 100644 --- a/robot-server/tests/data_files/test_data_files_store.py +++ b/robot-server/tests/data_files/test_data_files_store.py @@ -13,7 +13,11 @@ DataFileInfo, ) from robot_server.deletion_planner import FileUsageInfo -from robot_server.data_files.models import FileIdNotFoundError, FileInUseError +from robot_server.data_files.models import ( + DataFileSource, + FileIdNotFoundError, + FileInUseError, +) from robot_server.protocols.analysis_memcache import MemoryCache from robot_server.protocols.analysis_models import ( CompletedAnalysis, @@ -107,6 +111,7 @@ async def test_insert_data_file_info_and_fetch_by_hash( id="file-id", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), ) assert subject.get_file_info_by_hash("abc123") is None @@ -122,12 +127,14 @@ async def test_insert_file_info_with_existing_id( id="file-id", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), ) data_file_info2 = DataFileInfo( id="file-id", name="file-name2", file_hash="abc1234", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), ) await subject.insert(data_file_info1) @@ -143,6 +150,7 @@ async def test_insert_data_file_info_and_get_by_id( id="file-id", name="file-name", file_hash="abc", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=7, day=15, tzinfo=timezone.utc), ) await subject.insert(data_file_info) @@ -176,12 +184,14 @@ async def test_get_usage_info( id="file-id-1", name="file-name", file_hash="abc", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=7, day=15, tzinfo=timezone.utc), ) data_file_2 = DataFileInfo( id="file-id-2", name="file-name", file_hash="xyz", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=7, day=15, tzinfo=timezone.utc), ) await subject.insert(data_file_1) @@ -212,6 +222,7 @@ async def test_remove( id="file-id", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), ) await subject.insert(data_file_info) @@ -241,6 +252,7 @@ async def test_remove_raises_in_file_in_use( id="file-id", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), ) diff --git a/robot-server/tests/data_files/test_file_auto_deleter.py b/robot-server/tests/data_files/test_file_auto_deleter.py index 422af0891cb..000b55b4307 100644 --- a/robot-server/tests/data_files/test_file_auto_deleter.py +++ b/robot-server/tests/data_files/test_file_auto_deleter.py @@ -6,6 +6,7 @@ from robot_server.data_files.data_files_store import DataFilesStore from robot_server.data_files.file_auto_deleter import DataFileAutoDeleter +from robot_server.data_files.models import DataFileSource from robot_server.deletion_planner import DataFileDeletionPlanner, FileUsageInfo @@ -23,7 +24,9 @@ async def test_make_room_for_new_file( FileUsageInfo(file_id="file-2", used_by_run_or_analysis=True), ] decoy.when(mock_deletion_planner.maximum_allowed_files).then_return(1) - decoy.when(mock_data_files_store.get_usage_info()).then_return(files_usage) + decoy.when( + mock_data_files_store.get_usage_info(DataFileSource.UPLOADED) + ).then_return(files_usage) decoy.when(mock_deletion_planner.plan_for_new_file(files_usage)).then_return( {"id-to-be-deleted-1", "id-to-be-deleted-2"} ) diff --git a/robot-server/tests/data_files/test_router.py b/robot-server/tests/data_files/test_router.py index 751682fd422..ce10a9e56eb 100644 --- a/robot-server/tests/data_files/test_router.py +++ b/robot-server/tests/data_files/test_router.py @@ -10,8 +10,16 @@ from robot_server.service.json_api import MultiBodyMeta, SimpleEmptyBody -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo -from robot_server.data_files.models import DataFile, FileIdNotFoundError, FileInUseError +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) +from robot_server.data_files.models import ( + DataFile, + DataFileSource, + FileIdNotFoundError, + FileInUseError, +) from robot_server.data_files.router import ( upload_data_file, get_data_file_info_by_id, @@ -84,6 +92,7 @@ async def test_upload_new_data_file( id="data-file-id", name="abc.csv", createdAt=datetime(year=2024, month=6, day=18), + source=DataFileSource.UPLOADED, ) assert result.status_code == 201 decoy.verify( @@ -97,6 +106,7 @@ async def test_upload_new_data_file( name="abc.csv", file_hash="abc123", created_at=datetime(year=2024, month=6, day=18), + source=DataFileSource.UPLOADED, ) ), ) @@ -127,6 +137,7 @@ async def test_upload_existing_data_file( name="abc.csv", file_hash="abc123", created_at=datetime(year=2023, month=6, day=18), + source=DataFileSource.UPLOADED, ) ) @@ -146,6 +157,7 @@ async def test_upload_existing_data_file( id="existing-file-id", name="abc.csv", createdAt=datetime(year=2023, month=6, day=18), + source=DataFileSource.UPLOADED, ) @@ -183,6 +195,7 @@ async def test_upload_new_data_file_path( id="data-file-id", name="abc.csv", createdAt=datetime(year=2024, month=6, day=18), + source=DataFileSource.UPLOADED, ) decoy.verify( await file_reader_writer.write( @@ -194,6 +207,7 @@ async def test_upload_new_data_file_path( name="abc.csv", file_hash="abc123", created_at=datetime(year=2024, month=6, day=18), + source=DataFileSource.UPLOADED, ) ), ) @@ -270,6 +284,7 @@ async def test_get_data_file_info( name="abc.xyz", file_hash="123", created_at=datetime(year=2024, month=7, day=15), + source=DataFileSource.UPLOADED, ) ) @@ -282,6 +297,7 @@ async def test_get_data_file_info( id="qwerty", name="abc.xyz", createdAt=datetime(year=2024, month=7, day=15), + source=DataFileSource.UPLOADED, ) @@ -317,6 +333,7 @@ async def test_get_data_file( name="abc.xyz", file_hash="123", created_at=datetime(year=2024, month=7, day=15), + source=DataFileSource.UPLOADED, ) ) @@ -358,12 +375,14 @@ async def test_get_all_data_file_info( name="abc.xyz", file_hash="123", created_at=datetime(year=2024, month=7, day=15), + source=DataFileSource.UPLOADED, ), DataFileInfo( id="hfhcjdeowjfie", name="mcd.kfc", file_hash="124", created_at=datetime(year=2024, month=7, day=22), + source=DataFileSource.UPLOADED, ), ] ) @@ -376,11 +395,13 @@ async def test_get_all_data_file_info( id="qwerty", name="abc.xyz", createdAt=datetime(year=2024, month=7, day=15), + source=DataFileSource.UPLOADED, ), DataFile( id="hfhcjdeowjfie", name="mcd.kfc", createdAt=datetime(year=2024, month=7, day=22), + source=DataFileSource.UPLOADED, ), ] assert result.content.meta == MultiBodyMeta(cursor=0, totalLength=2) diff --git a/robot-server/tests/integration/http_api/data_files/test_deletion.tavern.yaml b/robot-server/tests/integration/http_api/data_files/test_deletion.tavern.yaml index 7cc4f90c4cc..301127df89e 100644 --- a/robot-server/tests/integration/http_api/data_files/test_deletion.tavern.yaml +++ b/robot-server/tests/integration/http_api/data_files/test_deletion.tavern.yaml @@ -21,6 +21,7 @@ stages: id: !anystr name: "sample_record.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" - name: Delete the data file request: diff --git a/robot-server/tests/integration/http_api/data_files/test_upload_data_file.tavern.yaml b/robot-server/tests/integration/http_api/data_files/test_upload_data_file.tavern.yaml index 44c5b4700f7..ee10cdadd9e 100644 --- a/robot-server/tests/integration/http_api/data_files/test_upload_data_file.tavern.yaml +++ b/robot-server/tests/integration/http_api/data_files/test_upload_data_file.tavern.yaml @@ -21,6 +21,7 @@ stages: id: !anystr name: "color_codes.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" - name: Upload same file again. It should not create a new record. request: @@ -49,6 +50,7 @@ stages: id: !anystr name: "sample_record.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" - name: Get color_codes.csv file info request: @@ -61,3 +63,4 @@ stages: id: '{data_file_id}' name: "color_codes.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml index addd5a43c79..d9a55f023d1 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml @@ -17,6 +17,7 @@ stages: csv_file_id: data.id csv_file_name: data.name file_created_at: data.createdAt + source: "uploaded" status_code: - 201 json: @@ -24,6 +25,7 @@ stages: id: !anystr name: "sample_record.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" - name: Upload a protocol request: @@ -40,6 +42,7 @@ stages: protocol_id: data.id analysis_id: data.analysisSummaries[0].id run_time_parameters_data1: data.analysisSummaries[0].runTimeParameters + source: "uploaded" strict: - json:off status_code: 201 @@ -130,3 +133,4 @@ stages: - id: '{csv_file_id}' name: '{csv_file_name}' createdAt: '{file_created_at}' + source: 'uploaded' diff --git a/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml index 63ac864856e..9b82f473491 100644 --- a/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml @@ -229,9 +229,12 @@ stages: - id: '{data_file_1_id}' name: "test.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: 'uploaded' - id: '{data_file_2_id}' name: "sample_record.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: 'uploaded' - id: '{data_file_3_id}' name: "sample_plates.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: 'uploaded' diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml index e89681be0ac..27f894fe411 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml @@ -101,7 +101,7 @@ stages: startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" status: failed - notes: [] + notes: !anylist error: id: !anystr errorType: TipNotAttachedError diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index 8916ebd1cf2..1f44f7101c7 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -34,6 +34,7 @@ stages: id: !anystr name: "sample_plates.csv" createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + source: "uploaded" - name: Create run from protocol request: diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index a970bb86d89..0277919aa30 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -117,6 +117,9 @@ CREATE UNIQUE INDEX ix_run_run_id_index_in_run ON run_command (run_id, index_in_run) """, """ + CREATE INDEX ix_data_files_source ON data_files (source) + """, + """ CREATE INDEX ix_protocol_protocol_kind ON protocol (protocol_kind) """, """ @@ -128,7 +131,9 @@ name VARCHAR NOT NULL, file_hash VARCHAR NOT NULL, created_at DATETIME NOT NULL, - PRIMARY KEY (id) + source VARCHAR(9) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT datafilesourcesqlenum CHECK (source IN ('uploaded', 'generated')) ) """, """ diff --git a/robot-server/tests/protocols/test_completed_analysis_store.py b/robot-server/tests/protocols/test_completed_analysis_store.py index 4426ad062c7..42c12565c14 100644 --- a/robot-server/tests/protocols/test_completed_analysis_store.py +++ b/robot-server/tests/protocols/test_completed_analysis_store.py @@ -8,6 +8,7 @@ from sqlalchemy.engine import Engine from decoy import Decoy +from robot_server.data_files.models import DataFileSource from robot_server.persistence.tables import ( analysis_table, analysis_primitive_type_rtp_table, @@ -21,7 +22,10 @@ ProtocolSource, JsonProtocolConfig, ) -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) from robot_server.protocols.analysis_memcache import MemoryCache from robot_server.protocols.analysis_models import ( CompletedAnalysis, @@ -326,6 +330,7 @@ async def test_store_and_get_csv_rtps_by_analysis_id( id="file-id", name="my_csv_file.csv", file_hash="file-hash", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), ) ) @@ -460,6 +465,7 @@ async def test_make_room_and_add_handles_rtp_tables_correctly( id="file-id", name="my_csv_file.csv", file_hash="file-hash", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), ) ) diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 5d413ad7fa3..ca965d471a8 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -14,8 +14,11 @@ PythonProtocolConfig, ) -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo -from robot_server.data_files.models import DataFile +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) +from robot_server.data_files.models import DataFile, DataFileSource from robot_server.protocols.analysis_memcache import MemoryCache from robot_server.protocols.analysis_models import ( CompletedAnalysis, @@ -588,6 +591,7 @@ async def test_get_referenced_data_files( id="data-file-id-1", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) ) @@ -596,6 +600,7 @@ async def test_get_referenced_data_files( id="data-file-id-2", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) ) @@ -604,6 +609,7 @@ async def test_get_referenced_data_files( id="data-file-id-3", name="file-name", file_hash="abc123", + source=DataFileSource.UPLOADED, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) ) @@ -653,15 +659,18 @@ async def test_get_referenced_data_files( id="data-file-id-1", name="file-name", createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + source=DataFileSource.UPLOADED, ), DataFile( id="data-file-id-2", name="file-name", createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + source=DataFileSource.UPLOADED, ), DataFile( id="data-file-id-3", name="file-name", createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + source=DataFileSource.UPLOADED, ), ] diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index a2ad10dbda0..637a2ee082f 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -30,8 +30,11 @@ BufferedFile, ) -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo -from robot_server.data_files.models import DataFile +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) +from robot_server.data_files.models import DataFile, DataFileSource from robot_server.errors.error_responses import ApiError from robot_server.protocols.analyses_manager import AnalysesManager from robot_server.protocols.protocol_analyzer import ProtocolAnalyzer @@ -728,6 +731,7 @@ async def test_create_new_protocol_with_run_time_params( id="123", name="file.abc", file_hash="xyz", + source=DataFileSource.UPLOADED, created_at=datetime(year=2022, month=2, day=2), ) ) @@ -993,6 +997,7 @@ async def test_create_existing_protocol_with_different_run_time_params( id="123", name="file.abc", file_hash="xyz", + source=DataFileSource.UPLOADED, created_at=datetime(year=2022, month=2, day=2), ) ) @@ -1813,6 +1818,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( id="123", name="foo.csv", file_hash="xyz", + source=DataFileSource.UPLOADED, created_at=datetime(year=2022, month=2, day=2), ) ) @@ -2172,11 +2178,13 @@ async def test_get_data_files( id="id1", name="csv-file1.csv", createdAt=datetime(year=2024, month=1, day=1), + source=DataFileSource.UPLOADED, ), DataFile( id="id2", name="csv-file2.csv", createdAt=datetime(year=2024, month=1, day=1), + source=DataFileSource.UPLOADED, ), ] decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) diff --git a/robot-server/tests/runs/router/conftest.py b/robot-server/tests/runs/router/conftest.py index 0ca0c5cc4f5..957f01c10dd 100644 --- a/robot-server/tests/runs/router/conftest.py +++ b/robot-server/tests/runs/router/conftest.py @@ -1,4 +1,5 @@ """Common test fixtures for runs route tests.""" +from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI import pytest from decoy import Decoy @@ -80,3 +81,9 @@ def mock_file_provider( ) -> FileProvider: """Return a mock FileProvider.""" return decoy.mock(cls=FileProvider) + + +@pytest.fixture +def mock_hardware_api(decoy: Decoy) -> HardwareControlAPI: + """Get a mock HardwareControlAPI.""" + return decoy.mock(cls=OT3HardwareControlAPI) diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 894950343e4..9052b588bc9 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -1,6 +1,8 @@ """Tests for base /runs routes.""" from typing import Dict +from opentrons.hardware_control import HardwareControlAPI +from opentrons_shared_data.robot.types import RobotTypeEnum import pytest from datetime import datetime from decoy import Decoy @@ -18,8 +20,12 @@ from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType, NozzleMap -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) +from robot_server.data_files.models import DataFileSource from robot_server.errors.error_responses import ApiError from robot_server.runs.error_recovery_models import ErrorRecoveryPolicy from robot_server.service.json_api import ( @@ -248,6 +254,7 @@ async def test_create_protocol_run( name="abc.xyz", file_hash="987", created_at=datetime(month=1, day=2, year=2024), + source=DataFileSource.UPLOADED, ) ) decoy.when( @@ -868,44 +875,54 @@ async def test_get_run_commands_errors_defualt_cursor( async def test_get_current_state_success( decoy: Decoy, mock_run_data_manager: RunDataManager, + mock_hardware_api: HardwareControlAPI, mock_nozzle_maps: Dict[str, NozzleMap], ) -> None: - """It should return the active nozzle layout for a specific pipette.""" + """It should return different state from the current run. + + - the active nozzle layout for a specific pipette. + - place plate reader state for absorbance reader. + """ run_id = "test-run-id" decoy.when(mock_run_data_manager.get_nozzle_maps(run_id=run_id)).then_return( mock_nozzle_maps ) + command_pointer = CommandPointer( + command_id="command-id", + command_key="command-key", + created_at=datetime(year=2024, month=4, day=4), + index=101, + ) decoy.when( mock_run_data_manager.get_last_completed_command(run_id=run_id) - ).then_return( - CommandPointer( - command_id="last-command-id", - command_key="last-command-key", - created_at=datetime(year=2024, month=4, day=4), - index=101, - ) + ).then_return(command_pointer) + decoy.when(mock_run_data_manager.get_current_command(run_id=run_id)).then_return( + command_pointer ) result = await get_current_state( runId=run_id, run_data_manager=mock_run_data_manager, + hardware=mock_hardware_api, + robot_type=RobotTypeEnum.FLEX, ) assert result.status_code == 200 assert result.content.data == RunCurrentState.construct( + estopEngaged=False, activeNozzleLayouts={ "mock-pipette-id": ActiveNozzleLayout( startingNozzle="A1", activeNozzles=["A1"], config=NozzleLayoutConfig.FULL, ) - } + }, ) assert result.content.links == CurrentStateLinks( lastCompleted=CommandLinkNoMeta( - href="/runs/test-run-id/commands/last-command-id", - id="last-command-id", + href="/runs/test-run-id/commands/command-id", + id="command-id", ) ) @@ -913,6 +930,7 @@ async def test_get_current_state_success( async def test_get_current_state_run_not_current( decoy: Decoy, mock_run_data_manager: RunDataManager, + mock_hardware_api: HardwareControlAPI, ) -> None: """It should raise RunStopped when the run is not current.""" run_id = "non-current-run-id" @@ -925,6 +943,8 @@ async def test_get_current_state_run_not_current( await get_current_state( runId=run_id, run_data_manager=mock_run_data_manager, + hardware=mock_hardware_api, + robot_type=RobotTypeEnum.FLEX, ) assert exc_info.value.status_code == 409 diff --git a/robot-server/tests/runs/test_error_recovery_mapping.py b/robot-server/tests/runs/test_error_recovery_mapping.py index a125d12649d..8b75ff99aad 100644 --- a/robot-server/tests/runs/test_error_recovery_mapping.py +++ b/robot-server/tests/runs/test_error_recovery_mapping.py @@ -72,7 +72,7 @@ def test_create_error_recovery_policy_with_rules( mock_error_data: CommandDefinedErrorData, mock_rule: ErrorRecoveryRule, ) -> None: - """Should return IGNORE_AND_CONTINUE if that's what we specify as the rule.""" + """Should return CONTINUE_WITH_ERROR if we specified IGNORE_AND_CONTINUE as the rule.""" policy = create_error_recovery_policy_from_rules([mock_rule], enabled=True) example_config = Config( robot_type="OT-3 Standard", @@ -80,7 +80,7 @@ def test_create_error_recovery_policy_with_rules( ) assert ( policy(example_config, mock_command, mock_error_data) - == ErrorRecoveryType.IGNORE_AND_CONTINUE + == ErrorRecoveryType.CONTINUE_WITH_ERROR ) @@ -141,7 +141,7 @@ def test_enabled_boolean(enabled: bool) -> None: policy = create_error_recovery_policy_from_rules(rules, enabled) result = policy(example_config, command, error_data) expected_result = ( - ErrorRecoveryType.IGNORE_AND_CONTINUE if enabled else ErrorRecoveryType.FAIL_RUN + ErrorRecoveryType.CONTINUE_WITH_ERROR if enabled else ErrorRecoveryType.FAIL_RUN ) assert result == expected_result @@ -187,7 +187,7 @@ def test_enabled_on_flex_disabled_on_ot2( policy = create_error_recovery_policy_from_rules(rules, enabled=True) result = policy(example_config, command, error_data) expected_result = ( - ErrorRecoveryType.IGNORE_AND_CONTINUE + ErrorRecoveryType.CONTINUE_WITH_ERROR if expect_error_recovery_to_be_enabled else ErrorRecoveryType.FAIL_RUN ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index b069632a4e4..7dbdf827012 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -304,7 +304,9 @@ def test_create_resume_from_recovery_action( ) decoy.verify(mock_run_store.insert_action(run_id, result), times=1) - decoy.verify(mock_run_orchestrator_store.resume_from_recovery()) + decoy.verify( + mock_run_orchestrator_store.resume_from_recovery(reconcile_false_positive=False) + ) @pytest.mark.parametrize( diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index ce6f8326c22..17a5c3b252f 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -5,13 +5,17 @@ import pytest from decoy import Decoy -from robot_server.data_files.data_files_store import DataFileInfo, DataFilesStore +from robot_server.data_files.data_files_store import ( + DataFileInfo, + DataFilesStore, +) from sqlalchemy.engine import Engine from unittest import mock from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.errors.codes import ErrorCodes +from robot_server.data_files.models import DataFileSource from robot_server.protocols.protocol_store import ProtocolNotFoundError from robot_server.runs.run_store import ( CSVParameterRunResource, @@ -296,6 +300,7 @@ async def test_insert_and_get_csv_rtp( id="file-id", name="my_csv_file.csv", file_hash="file-hash", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), ) ) @@ -524,6 +529,7 @@ async def test_remove_run( id="file-id", name="my_csv_file.csv", file_hash="file-hash", + source=DataFileSource.UPLOADED, created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), ) ) diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index be8e870c5bb..93bc2387d63 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -76,7 +76,8 @@ "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate", "unsafe/updatePositionEstimators": "#/definitions/UpdatePositionEstimatorsCreate", "unsafe/engageAxes": "#/definitions/UnsafeEngageAxesCreate", - "unsafe/ungripLabware": "#/definitions/UnsafeUngripLabwareCreate" + "unsafe/ungripLabware": "#/definitions/UnsafeUngripLabwareCreate", + "unsafe/placeLabware": "#/definitions/UnsafePlaceLabwareCreate" } }, "oneOf": [ @@ -295,6 +296,9 @@ }, { "$ref": "#/definitions/UnsafeUngripLabwareCreate" + }, + { + "$ref": "#/definitions/UnsafePlaceLabwareCreate" } ], "definitions": { @@ -4739,6 +4743,67 @@ } }, "required": ["params"] + }, + "UnsafePlaceLabwareParams": { + "title": "UnsafePlaceLabwareParams", + "description": "Payload required for an UnsafePlaceLabware command.", + "type": "object", + "properties": { + "labwareId": { + "title": "Labwareid", + "description": "The id of the labware to place.", + "type": "string" + }, + "location": { + "title": "Location", + "description": "Where to place the labware.", + "anyOf": [ + { + "$ref": "#/definitions/DeckSlotLocation" + }, + { + "$ref": "#/definitions/ModuleLocation" + }, + { + "$ref": "#/definitions/OnLabwareLocation" + }, + { + "$ref": "#/definitions/AddressableAreaLocation" + } + ] + } + }, + "required": ["labwareId", "location"] + }, + "UnsafePlaceLabwareCreate": { + "title": "UnsafePlaceLabwareCreate", + "description": "UnsafePlaceLabware command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/placeLabware", + "enum": ["unsafe/placeLabware"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafePlaceLabwareParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] } }, "$id": "opentronsCommandSchemaV10", diff --git a/shared-data/command/types/setup.ts b/shared-data/command/types/setup.ts index 0be40e6de13..13d29c682b4 100644 --- a/shared-data/command/types/setup.ts +++ b/shared-data/command/types/setup.ts @@ -106,6 +106,12 @@ export type LabwareLocation = | { labwareId: string } | { addressableAreaName: AddressableAreaName } +export type OnDeckLabwareLocation = + | { slotName: string } + | { moduleId: string } + | { labwareId: string } + | { addressableAreaName: AddressableAreaName } + export type NonStackedLocation = | 'offDeck' | { slotName: string } diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index d24a6f8e054..3875aaa3036 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -1,4 +1,8 @@ -import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { + CommonCommandRunTimeInfo, + CommonCommandCreateInfo, + OnDeckLabwareLocation, +} from '.' import type { MotorAxes } from '../../js/types' export type UnsafeRunTimeCommand = @@ -7,6 +11,7 @@ export type UnsafeRunTimeCommand = | UnsafeUpdatePositionEstimatorsRunTimeCommand | UnsafeEngageAxesRunTimeCommand | UnsafeUngripLabwareRunTimeCommand + | UnsafePlaceLabwareRunTimeCommand export type UnsafeCreateCommand = | UnsafeBlowoutInPlaceCreateCommand @@ -14,6 +19,7 @@ export type UnsafeCreateCommand = | UnsafeUpdatePositionEstimatorsCreateCommand | UnsafeEngageAxesCreateCommand | UnsafeUngripLabwareCreateCommand + | UnsafePlaceLabwareCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string @@ -85,3 +91,17 @@ export interface UnsafeUngripLabwareRunTimeCommand UnsafeUngripLabwareCreateCommand { result?: any } +export interface UnsafePlaceLabwareParams { + labwareId: string + location: OnDeckLabwareLocation +} +export interface UnsafePlaceLabwareCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/placeLabware' + params: UnsafePlaceLabwareParams +} +export interface UnsafePlaceLabwareRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafePlaceLabwareCreateCommand { + result?: any +} diff --git a/shared-data/js/getLabware.ts b/shared-data/js/getLabware.ts index 6fd22f1f5d4..41949e01d9a 100644 --- a/shared-data/js/getLabware.ts +++ b/shared-data/js/getLabware.ts @@ -57,8 +57,9 @@ export const PD_DO_NOT_LIST = [ 'opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat', 'opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep', 'opentrons_96_pcr_adapter_armadillo_wellplate_200ul', - // temporarily blocking TC lid adapter until it is supported in PD + // temporarily blocking TC lid adapter and deck riser until it is supported in PD 'opentrons_tough_pcr_auto_sealing_lid', + 'opentrons_flex_deck_riser', ] export function getIsLabwareV1Tiprack(def: LabwareDefinition1): boolean { diff --git a/shared-data/js/helpers/parseProtocolCommands.ts b/shared-data/js/helpers/parseProtocolCommands.ts index a5a05a65636..4f111e4d3e5 100644 --- a/shared-data/js/helpers/parseProtocolCommands.ts +++ b/shared-data/js/helpers/parseProtocolCommands.ts @@ -10,6 +10,7 @@ import type { LoadModuleRunTimeCommand, LoadPipetteRunTimeCommand, RunTimeCommand, + LabwareLocation, } from '../../command/types' import type { PipetteName } from '../pipettes' import type { @@ -18,6 +19,7 @@ import type { LoadedModule, LoadedPipette, ModuleModel, + LabwareDefinition2, } from '../types' interface PipetteNamesByMount { @@ -135,6 +137,111 @@ export function parseInitialLoadedLabwareBySlot( ) } +// given a labware id and load labware commands, this function +// finds the top most labware in the stack and returns relevant +// information +export function getTopLabwareInfo( + labwareId: string, + loadLabwareCommands: LoadLabwareRunTimeCommand[], + currentStackHeight: number = 0 +): { + topLabwareId: string + topLabwareDefinition?: LabwareDefinition2 + topLabwareDisplayName?: string +} { + const nestedCommand = loadLabwareCommands.find( + command => + command.commandType === 'loadLabware' && + command.params.location !== 'offDeck' && + 'labwareId' in command.params.location && + command.params.location.labwareId === labwareId + ) + // prevent recurssion errors (like labware stacked on itself) + // by enforcing a max stack height + if (nestedCommand == null || currentStackHeight > 5) { + const loadCommand = loadLabwareCommands.find( + command => + command.commandType === 'loadLabware' && + command.result?.labwareId === labwareId + ) + if (loadCommand == null) { + console.warn( + `could not find the load labware command assosciated with thie labwareId: ${labwareId}` + ) + } + return { + topLabwareId: labwareId, + topLabwareDefinition: loadCommand?.result?.definition, + topLabwareDisplayName: loadCommand?.params.displayName, + } + } else { + return getTopLabwareInfo( + nestedCommand?.result?.labwareId as string, + loadLabwareCommands, + currentStackHeight + 1 + ) + } +} + +// this recursive function will parse through load labware commands +// and give the quantity of LIKE labware stacked below the given labware +// id and the labware location of the bottom-most stacked item +export function getLabwareStackCountAndLocation( + topLabwareId: string, + commands: RunTimeCommand[], + initialQuantity: number = 1 +): { labwareQuantity: number; labwareLocation: LabwareLocation } { + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + const loadLabwareCommand = loadLabwareCommands?.find( + command => command.result?.labwareId === topLabwareId + ) + + if (loadLabwareCommands == null || loadLabwareCommand == null) { + console.warn( + `could not find the load labware command assosciated with thie labwareId: ${topLabwareId}` + ) + return { labwareLocation: 'offDeck', labwareQuantity: 0 } + } + + const labwareLocation = loadLabwareCommand.params.location + + if (labwareLocation !== 'offDeck' && 'labwareId' in labwareLocation) { + const lowerLabwareCommand = loadLabwareCommands?.find(command => + command.result != null + ? command.result?.labwareId === labwareLocation.labwareId + : '' + ) + if (lowerLabwareCommand?.result?.labwareId == null) { + console.warn( + `could not find the load labware command assosciated with thie labwareId: ${labwareLocation.labwareId}` + ) + return { labwareLocation: 'offDeck', labwareQuantity: 0 } + } + + const isSameLabware = + loadLabwareCommand.params.loadName === + lowerLabwareCommand?.params.loadName + + // add protection for recursion errors by having a max stack of 5 which is current + // allowed max stack of TC lids + if (isSameLabware && initialQuantity < 5) { + const newQuantity = initialQuantity + 1 + return getLabwareStackCountAndLocation( + lowerLabwareCommand.result.labwareId, + commands, + newQuantity + ) + } + } + return { + labwareQuantity: initialQuantity, + labwareLocation, + } +} + export interface LoadedLabwareByAdapter { [labwareId: string]: LoadLabwareRunTimeCommand } diff --git a/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json b/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json index 6c0cd91a11b..59f0548ca32 100644 --- a/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json +++ b/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json @@ -34,8 +34,8 @@ "schemaVersion": 2, "allowedRoles": ["adapter"], "cornerOffsetFromSlot": { - "x": 0, - "y": 0, + "x": -6.125, + "y": -6.125, "z": 0 } }