From 71ae62eb0bafffdc65d153511e255765030dd173 Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Thu, 28 Mar 2024 11:56:13 -0400 Subject: [PATCH 01/49] chore(shared-data): Add definition for 96ch v3.6 with 3.0mm backlash (#14721) ncreased backlash compensation improves low-volume performance. NOTE: accuracy functions are copied over from v3.5 definition, will add new v3.6 functions in a follow-up PR estimated to be ready around April 3-8 when the data is ready --- .../definitions/1/pipetteModelSpecs.json | 169 ++++++++++ .../general/ninety_six_channel/p1000/3_6.json | 87 ++++++ .../ninety_six_channel/p1000/3_6.json | 295 ++++++++++++++++++ .../ninety_six_channel/p1000/default/3_6.json | 188 +++++++++++ 4 files changed, 739 insertions(+) create mode 100644 shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json create mode 100644 shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json create mode 100644 shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json diff --git a/shared-data/pipette/definitions/1/pipetteModelSpecs.json b/shared-data/pipette/definitions/1/pipetteModelSpecs.json index 7a039c0e33f..c6367e851b4 100644 --- a/shared-data/pipette/definitions/1/pipetteModelSpecs.json +++ b/shared-data/pipette/definitions/1/pipetteModelSpecs.json @@ -10304,6 +10304,175 @@ "quirks": [], "returnTipHeight": 0.83, "idleCurrent": 0.3 + }, + "p1000_96_v3.6": { + "name": "p1000_96", + "backCompatNames": [], + "top": { + "value": 0.5, + "min": 0, + "max": 45, + "units": "mm", + "type": "float" + }, + "bottom": { + "value": 71.5, + "min": 55, + "max": 80, + "type": "float", + "units": "mm" + }, + "blowout": { + "value": 76.5, + "min": 60, + "max": 85, + "units": "mm", + "type": "float" + }, + "dropTip": { + "value": 92.5, + "min": 78, + "max": 119, + "units": "mm", + "type": "float" + }, + "pickUpCurrent": { + "value": 0.5, + "min": 0.05, + "max": 2.0, + "units": "amps", + "type": "float" + }, + "pickUpDistance": { + "value": 13, + "min": 1, + "max": 30, + "units": "mm", + "type": "float" + }, + "pickUpIncrement": { + "value": 0.0, + "min": 0.0, + "max": 10.0, + "units": "mm", + "type": "float" + }, + "pickUpPresses": { + "value": 1, + "min": 0, + "max": 10, + "units": "presses", + "type": "int" + }, + "pickUpSpeed": { + "value": 10, + "min": 1, + "max": 30, + "units": "mm/s", + "type": "float" + }, + "nozzleOffset": [-8.0, -16.0, -259.15], + "modelOffset": [0.0, 0.0, 25.14], + "ulPerMm": [ + { + "aspirate": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ], + + "dispense": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + } + ], + "plungerCurrent": { + "value": 1, + "min": 0.1, + "max": 1.5, + "units": "amps", + "type": "float" + }, + "dropTipCurrent": { + "value": 1, + "min": 0.1, + "max": 1.25, + "units": "amps", + "type": "float" + }, + "dropTipSpeed": { + "value": 10, + "min": 0.001, + "max": 30, + "units": "mm/sec", + "type": "float" + }, + "tipOverlap": { + "default": 10.5, + "opentrons/opentrons_96_tiprack_50ul/1": 10.5 + }, + "tipLength": { + "value": 78.3, + "units": "mm", + "type": "float", + "min": 0, + "max": 100 + }, + "quirks": [], + "returnTipHeight": 0.83, + "idleCurrent": 0.3 } }, "mutableConfigs": [ diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json new file mode 100644 index 00000000000..a00dce8ef17 --- /dev/null +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json @@ -0,0 +1,87 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "Flex 96-Channel 1000 μL", + "model": "p1000", + "displayCategory": "FLEX", + "pickUpTipConfigurations": { + "pressFit": { + "presses": 1, + "increment": 0.0, + "speed": 10.0, + "distance": 13.0, + "currentByTipCount": { + "1": 0.2, + "2": 0.25, + "3": 0.3, + "4": 0.35, + "5": 0.4, + "6": 0.45, + "7": 0.5, + "8": 0.55, + "12": 0.19, + "16": 0.25, + "24": 0.38, + "48": 0.75 + } + }, + "camAction": { + "speed": 5.5, + "distance": 10.0, + "prep_move_distance": 8.25, + "prep_move_speed": 10.0, + "connectTiprackDistanceMM": 7.0, + "currentByTipCount": { + "96": 1.5 + } + } + }, + "dropTipConfigurations": { + "camAction": { + "current": 1.5, + "speed": 5.5, + "distance": 10.8, + "prep_move_distance": 19.0, + "prep_move_speed": 10.0 + } + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 0.8 + }, + "plungerPositionsConfigurations": { + "default": { + "top": 0.5, + "bottom": 68.5, + "blowout": 73.5, + "drop": 80 + } + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 8, 12, 16, 24, 48, 96] + }, + "backCompatNames": [], + "channels": 96, + "shaftDiameter": 4.5, + "shaftULperMM": 15.904, + "backlashDistance": 3.0, + "quirks": [], + "plungerHomingConfigurations": { + "current": 0.8, + "speed": 5 + }, + "tipPresenceCheckDistanceMM": 8.0, + "endTipActionRetractDistanceMM": 2.0 +} diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json new file mode 100644 index 00000000000..da209a72907 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json @@ -0,0 +1,295 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/geometry/ninety_six_channel/p1000/placeholder.gltf", + "nozzleOffset": [-36.0, -25.5, -259.15], + "pipetteBoundingBoxOffsets": { + "backLeftCorner": [-67.0, -3.5, -259.15], + "frontRightCorner": [94.0, -113.0, -259.15] + }, + "orderedRows": [ + { + "key": "A", + "orderedNozzles": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12" + ] + }, + { + "key": "B", + "orderedNozzles": [ + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "B10", + "B11", + "B12" + ] + }, + { + "key": "C", + "orderedNozzles": [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12" + ] + }, + { + "key": "D", + "orderedNozzles": [ + "D1", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "D10", + "D11", + "D12" + ] + }, + { + "key": "E", + "orderedNozzles": [ + "E1", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "E10", + "E11", + "E12" + ] + }, + { + "key": "F", + "orderedNozzles": [ + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12" + ] + }, + { + "key": "G", + "orderedNozzles": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "G10", + "G11", + "G12" + ] + }, + { + "key": "H", + "orderedNozzles": [ + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9", + "H10", + "H11", + "H12" + ] + } + ], + "orderedColumns": [ + { + "key": "1", + "orderedNozzles": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"] + }, + { + "key": "2", + "orderedNozzles": ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"] + }, + { + "key": "3", + "orderedNozzles": ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"] + }, + { + "key": "4", + "orderedNozzles": ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"] + }, + { + "key": "5", + "orderedNozzles": ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"] + }, + { + "key": "6", + "orderedNozzles": ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"] + }, + { + "key": "7", + "orderedNozzles": ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"] + }, + { + "key": "8", + "orderedNozzles": ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"] + }, + { + "key": "9", + "orderedNozzles": ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"] + }, + { + "key": "10", + "orderedNozzles": ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"] + }, + { + "key": "11", + "orderedNozzles": ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"] + }, + { + "key": "12", + "orderedNozzles": ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + } + ], + "nozzleMap": { + "A1": [-36.0, -25.5, -259.15], + "A2": [-27.0, -25.5, -259.15], + "A3": [-18.0, -25.5, -259.15], + "A4": [-9.0, -25.5, -259.15], + "A5": [0.0, -25.5, -259.15], + "A6": [9.0, -25.5, -259.15], + "A7": [18.0, -25.5, -259.15], + "A8": [27.0, -25.5, -259.15], + "A9": [36.0, -25.5, -259.15], + "A10": [45.0, -25.5, -259.15], + "A11": [54.0, -25.5, -259.15], + "A12": [63.0, -25.5, -259.15], + "B1": [-36.0, -34.5, -259.15], + "B2": [-27.0, -34.5, -259.15], + "B3": [-18.0, -34.5, -259.15], + "B4": [-9.0, -34.5, -259.15], + "B5": [0.0, -34.5, -259.15], + "B6": [9.0, -34.5, -259.15], + "B7": [18.0, -34.5, -259.15], + "B8": [27.0, -34.5, -259.15], + "B9": [36.0, -34.5, -259.15], + "B10": [45.0, -34.5, -259.15], + "B11": [54.0, -34.5, -259.15], + "B12": [63.0, -34.5, -259.15], + "C1": [-36.0, -43.5, -259.15], + "C2": [-27.0, -43.5, -259.15], + "C3": [-18.0, -43.5, -259.15], + "C4": [-9.0, -43.5, -259.15], + "C5": [0.0, -43.5, -259.15], + "C6": [9.0, -43.5, -259.15], + "C7": [18.0, -43.5, -259.15], + "C8": [27.0, -43.5, -259.15], + "C9": [36.0, -43.5, -259.15], + "C10": [45.0, -43.5, -259.15], + "C11": [54.0, -43.5, -259.15], + "C12": [63.0, -43.5, -259.15], + "D1": [-36.0, -52.5, -259.15], + "D2": [-27.0, -52.5, -259.15], + "D3": [-18.0, -52.5, -259.15], + "D4": [-9.0, -52.5, -259.15], + "D5": [0.0, -52.5, -259.15], + "D6": [9.0, -52.5, -259.15], + "D7": [18.0, -52.5, -259.15], + "D8": [27.0, -52.5, -259.15], + "D9": [36.0, -52.5, -259.15], + "D10": [45.0, -52.5, -259.15], + "D11": [54.0, -52.5, -259.15], + "D12": [63.0, -52.5, -259.15], + "E1": [-36.0, -61.5, -259.15], + "E2": [-27.0, -61.5, -259.15], + "E3": [-18.0, -61.5, -259.15], + "E4": [-9.0, -61.5, -259.15], + "E5": [0.0, -61.5, -259.15], + "E6": [9.0, -61.5, -259.15], + "E7": [18.0, -61.5, -259.15], + "E8": [27.0, -61.5, -259.15], + "E9": [36.0, -61.5, -259.15], + "E10": [45.0, -61.5, -259.15], + "E11": [54.0, -61.5, -259.15], + "E12": [63.0, -61.5, -259.15], + "F1": [-36.0, -70.5, -259.15], + "F2": [-27.0, -70.5, -259.15], + "F3": [-18.0, -70.5, -259.15], + "F4": [-9.0, -70.5, -259.15], + "F5": [0.0, -70.5, -259.15], + "F6": [9.0, -70.5, -259.15], + "F7": [18.0, -70.5, -259.15], + "F8": [27.0, -70.5, -259.15], + "F9": [36.0, -70.5, -259.15], + "F10": [45.0, -70.5, -259.15], + "F11": [54.0, -70.5, -259.15], + "F12": [63.0, -70.5, -259.15], + "G1": [-36.0, -79.5, -259.15], + "G2": [-27.0, -79.5, -259.15], + "G3": [-18.0, -79.5, -259.15], + "G4": [-9.0, -79.5, -259.15], + "G5": [0.0, -79.5, -259.15], + "G6": [9.0, -79.5, -259.15], + "G7": [18.0, -79.5, -259.15], + "G8": [27.0, -79.5, -259.15], + "G9": [36.0, -79.5, -259.15], + "G10": [45.0, -79.5, -259.15], + "G11": [54.0, -79.5, -259.15], + "G12": [63.0, -79.5, -259.15], + "H1": [-36.0, -88.5, -259.15], + "H2": [-27.0, -88.5, -259.15], + "H3": [-18.0, -88.5, -259.15], + "H4": [-9.0, -88.5, -259.15], + "H5": [0.0, -88.5, -259.15], + "H6": [9.0, -88.5, -259.15], + "H7": [18.0, -88.5, -259.15], + "H8": [27.0, -88.5, -259.15], + "H9": [36.0, -88.5, -259.15], + "H10": [45.0, -88.5, -259.15], + "H11": [54.0, -88.5, -259.15], + "H12": [63.0, -88.5, -259.15] + } +} diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json new file mode 100644 index 00000000000..8ca9dc4ece4 --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json @@ -0,0 +1,188 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultDispenseFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 57.9, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.933333, 2.844459, 4.750159], + [2.833333, 1.12901, 8.066694], + [3.603333, 0.254744, 10.543779], + [4.836667, 1.101839, 7.491414], + [5.755, 0.277649, 11.47775], + [6.643333, 0.14813, 12.223126], + [7.548333, 0.145635, 12.239705], + [8.475, 0.15097, 12.199433], + [13.02, 0.071736, 12.870946], + [22.318333, 0.042305, 13.254131], + [36.463333, 0.021195, 13.725284], + [54.82, 0.001805, 14.43229] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.933333, 2.844459, 4.750159], + [2.833333, 1.12901, 8.066694], + [3.603333, 0.254744, 10.543779], + [4.836667, 1.101839, 7.491414], + [5.755, 0.277649, 11.47775], + [6.643333, 0.14813, 12.223126], + [7.548333, 0.145635, 12.239705], + [8.475, 0.15097, 12.199433], + [13.02, 0.071736, 12.870946], + [22.318333, 0.042305, 13.254131], + [36.463333, 0.021195, 13.725284], + [54.82, 0.001805, 14.43229] + ] + } + }, + "defaultPushOutVolume": 7 + }, + "t200": { + "defaultAspirateFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultDispenseFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 58.35, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.39875, 4.681865, 0.866627], + [2.5225, 2.326382, 4.161359], + [3.625, 1.361424, 6.595466], + [4.69125, 0.848354, 8.455342], + [5.705, 0.519685, 9.997214], + [6.70625, 0.36981, 10.852249], + [7.69375, 0.267029, 11.541523], + [8.67875, 0.210129, 11.979299], + [47.05, 0.030309, 13.539909], + [95.24375, 0.003774, 14.78837], + [211.0225, 0.000928, 15.059476] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.39875, 4.681865, 0.866627], + [2.5225, 2.326382, 4.161359], + [3.625, 1.361424, 6.595466], + [4.69125, 0.848354, 8.455342], + [5.705, 0.519685, 9.997214], + [6.70625, 0.36981, 10.852249], + [7.69375, 0.267029, 11.541523], + [8.67875, 0.210129, 11.979299], + [47.05, 0.030309, 13.539909], + [95.24375, 0.003774, 14.78837], + [211.0225, 0.000928, 15.059476] + ] + } + }, + "defaultPushOutVolume": 5 + }, + "t1000": { + "defaultAspirateFlowRate": { + "default": 160, + "valuesByApiLevel": { "2.14": 160 } + }, + "defaultDispenseFlowRate": { + "default": 160, + "valuesByApiLevel": { "2.14": 160 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 95.6, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [3.76, 2.041301, 4.284751], + [5.684286, 0.49624, 10.09418], + [8.445714, 0.187358, 11.849952], + [12.981429, 0.073135, 12.814653], + [17.667143, 0.060853, 12.974083], + [46.515714, 0.025888, 13.591828], + [95.032857, 0.006561, 14.490827], + [114.488571, 0.00306, 14.823556], + [192.228571, 0.001447, 15.00822], + [309.921429, 0.000995, 15.095087], + [436.984286, 0.000322, 15.303634], + [632.861429, 0.000208, 15.353582], + [828.952857, 0.00013, 15.402544], + [976.118571, 0.000095, 15.431673], + [1005.275714, -0.000067, 15.589843], + [1024.768571, -0.000021, 15.543681], + [1049.145714, -0.000013, 15.535884] + ] + } + }, + "dispense": { + "default": { + "1": [ + [3.76, 2.041301, 4.284751], + [5.684286, 0.49624, 10.09418], + [8.445714, 0.187358, 11.849952], + [12.981429, 0.073135, 12.814653], + [17.667143, 0.060853, 12.974083], + [46.515714, 0.025888, 13.591828], + [95.032857, 0.006561, 14.490827], + [114.488571, 0.00306, 14.823556], + [192.228571, 0.001447, 15.00822], + [309.921429, 0.000995, 15.095087], + [436.984286, 0.000322, 15.303634], + [632.861429, 0.000208, 15.353582], + [828.952857, 0.00013, 15.402544], + [976.118571, 0.000095, 15.431673], + [1005.275714, -0.000067, 15.589843], + [1024.768571, -0.000021, 15.543681], + [1049.145714, -0.000013, 15.535884] + ] + } + }, + "defaultPushOutVolume": 20 + } + }, + "defaultTipOverlapDictionary": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_1000ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 + }, + "maxVolume": 1000, + "minVolume": 5, + "defaultTipracks": [ + "opentrons/opentrons_flex_96_tiprack_1000ul/1", + "opentrons/opentrons_flex_96_tiprack_200ul/1", + "opentrons/opentrons_flex_96_tiprack_50ul/1" + ] +} From 4d3b566766cde289dafd694a0a83a8e560d348cd Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Thu, 28 Mar 2024 13:56:43 -0400 Subject: [PATCH 02/49] chore(release): 7.2.2 release notes (#14747) # Overview 7.2.2 hotfix release notes. # Changelog - Describes #14721 - No others so far # Review requests Anything else of note going into this release? # Risk assessment nil --- api/release-notes.md | 10 ++++++++++ app-shell/build/release-notes.md | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/api/release-notes.md b/api/release-notes.md index ff193247459..a1db0c0e1f3 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,6 +6,16 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons Robot Software Changes in 7.2.2 + +Welcome to the v7.2.2 release of the Opentrons robot software! + +### Improved Features + +- Improved the low-volume performance of recently produced Flex 96-Channel Pipettes. + +--- + ## Opentrons Robot Software Changes in 7.2.1 Welcome to the v7.2.1 release of the Opentrons robot software! diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 97fa5f01b81..43db1bdfaf8 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,6 +6,14 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons App Changes in 7.2.2 + +Welcome to the v7.2.2 release of the Opentrons App! + +There are no changes to the Opentrons App in v7.2.2, but it is required for updating the robot software to improve some features. + +--- + ## Opentrons App Changes in 7.2.1 Welcome to the v7.2.1 release of the Opentrons App! From 4bf9073f9b1e491da05ff95fe669a08010abb0ad Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Mon, 1 Apr 2024 13:56:50 -0400 Subject: [PATCH 03/49] chore(release): add release note for speaker and camera (#14768) Release note for OT-2 speaker and camera fix yet to be merged. --- api/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/release-notes.md b/api/release-notes.md index a1db0c0e1f3..046b3e1e04b 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -14,6 +14,10 @@ Welcome to the v7.2.2 release of the Opentrons robot software! - Improved the low-volume performance of recently produced Flex 96-Channel Pipettes. +### Bug Fixes + +- Restores the ability to use the speaker and camera on OT-2. + --- ## Opentrons Robot Software Changes in 7.2.1 From 8f2c5e339d4c7901ee1f3cf4393d2230130d7676 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Wed, 3 Apr 2024 12:24:38 -0400 Subject: [PATCH 04/49] fix(api): Change the camera device to /dev/camera2 (#14790) We updated the Linux Kernel recently which has changed the camera device from /dev/video0 to /dev/video2, so lets change it here. This pull request pertains to [Opentrons/oe-core#140](https://github.com/Opentrons/oe-core/pull/140). Closes: [EXEC-354](https://opentrons.atlassian.net/browse/EXEC-354) # Test Plan - [x] Make sure we can take a picture via `/camera/picture` endpoint. # Changelog - set the camera device to /dev/video2 when taking a picture # Review requests # Risk assessment [EXEC-354]: https://opentrons.atlassian.net/browse/EXEC-354?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Seth Foster --- api/release-notes.md | 1 + api/src/opentrons/system/camera.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/release-notes.md b/api/release-notes.md index 046b3e1e04b..ca9523121b4 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -17,6 +17,7 @@ Welcome to the v7.2.2 release of the Opentrons robot software! ### Bug Fixes - Restores the ability to use the speaker and camera on OT-2. +- Restores the ability to use the camera on Flex. --- diff --git a/api/src/opentrons/system/camera.py b/api/src/opentrons/system/camera.py index 1c2d09d8747..761a9ba66a1 100644 --- a/api/src/opentrons/system/camera.py +++ b/api/src/opentrons/system/camera.py @@ -1,6 +1,7 @@ import asyncio import os from pathlib import Path + from opentrons.config import ARCHITECTURE, SystemArchitecture from opentrons_shared_data.errors.exceptions import CommunicationError from opentrons_shared_data.errors.codes import ErrorCodes @@ -29,7 +30,7 @@ async def take_picture(filename: Path) -> None: pass if ARCHITECTURE == SystemArchitecture.YOCTO: - cmd = f"v4l2-ctl --device /dev/video0 --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1" + cmd = f"v4l2-ctl --device /dev/video2 --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1" elif ARCHITECTURE == SystemArchitecture.BUILDROOT: cmd = f"ffmpeg -f video4linux2 -s 640x480 -i /dev/video0 -ss 0:0:1 -frames 1 {str(filename)}" else: # HOST From 68e250c15d2bf76f2da5015ec7e1a15610f1e812 Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 8 Apr 2024 12:15:10 -0400 Subject: [PATCH 05/49] refactor(app): update storybook of medium button (#14760) * refactor(app): update storybook of medium button --- .../atoms/buttons/MediumButton.stories.tsx | 95 ++++++++++--------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/app/src/atoms/buttons/MediumButton.stories.tsx b/app/src/atoms/buttons/MediumButton.stories.tsx index 667947b7e08..6c7fbd2fe5b 100644 --- a/app/src/atoms/buttons/MediumButton.stories.tsx +++ b/app/src/atoms/buttons/MediumButton.stories.tsx @@ -1,73 +1,74 @@ -import * as React from 'react' import { ICON_DATA_BY_NAME, VIEWPORT } from '@opentrons/components' import { MediumButton } from './' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'ODD/Atoms/Buttons/MediumButton', + component: MediumButton, argTypes: { iconName: { control: { type: 'select', - options: Object.keys(ICON_DATA_BY_NAME), }, - defaultValue: undefined, + options: Object.keys(ICON_DATA_BY_NAME), }, buttonCategory: { control: { type: 'select', - options: ['default', 'rounded'], }, - defaultValue: undefined, + options: ['default', 'rounded'], }, onClick: { action: 'clicked' }, - width: { - control: { - type: 'text', - }, - defaultValue: undefined, - }, }, parameters: VIEWPORT.touchScreenViewport, -} as Meta +} -const MediumButtonTemplate: Story< - React.ComponentProps -> = args => +export default meta +type Story = StoryObj -export const PrimaryMediumButton = MediumButtonTemplate.bind({}) -PrimaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'primary', - disabled: false, +export const PrimaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'primary', + disabled: false, + }, } -export const SecondaryMediumButton = MediumButtonTemplate.bind({}) -SecondaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'secondary', - disabled: false, + +export const SecondaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'secondary', + disabled: false, + }, } -export const AlertMediumButton = MediumButtonTemplate.bind({}) -AlertMediumButton.args = { - buttonText: 'Button text', - buttonType: 'alert', - disabled: false, + +export const AlertMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'alert', + disabled: false, + }, } -export const AlertSecondaryMediumButton = MediumButtonTemplate.bind({}) -AlertSecondaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'alertSecondary', - disabled: false, +export const AlertSecondaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'alertSecondary', + disabled: false, + }, } -export const TertiaryHighMediumButton = MediumButtonTemplate.bind({}) -TertiaryHighMediumButton.args = { - buttonText: 'Button text', - buttonType: 'tertiaryHigh', - disabled: false, + +export const TertiaryHighMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'tertiaryHigh', + disabled: false, + }, } -export const TertiaryLowLightMediumButton = MediumButtonTemplate.bind({}) -TertiaryLowLightMediumButton.args = { - buttonText: 'Button text', - buttonType: 'tertiaryLowLight', - disabled: false, + +export const TertiaryLowLightMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'tertiaryLowLight', + disabled: false, + }, } From 22959a6e40c165db4156b46b05a19c611ef3abc3 Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Mon, 8 Apr 2024 12:29:40 -0400 Subject: [PATCH 06/49] chore(shared-data): Adds new functions for v36 96ch (#14792) Functions for 50, 200, and 1000 ul tips for the `v3.6` 96ch pipettes --- .../ninety_six_channel/p1000/default/3_6.json | 160 +++++++++--------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json index 8ca9dc4ece4..cac57c41844 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json @@ -20,36 +20,36 @@ "aspirate": { "default": { "1": [ - [1.933333, 2.844459, 4.750159], - [2.833333, 1.12901, 8.066694], - [3.603333, 0.254744, 10.543779], - [4.836667, 1.101839, 7.491414], - [5.755, 0.277649, 11.47775], - [6.643333, 0.14813, 12.223126], - [7.548333, 0.145635, 12.239705], - [8.475, 0.15097, 12.199433], - [13.02, 0.071736, 12.870946], - [22.318333, 0.042305, 13.254131], - [36.463333, 0.021195, 13.725284], - [54.82, 0.001805, 14.43229] + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] ] } }, "dispense": { "default": { "1": [ - [1.933333, 2.844459, 4.750159], - [2.833333, 1.12901, 8.066694], - [3.603333, 0.254744, 10.543779], - [4.836667, 1.101839, 7.491414], - [5.755, 0.277649, 11.47775], - [6.643333, 0.14813, 12.223126], - [7.548333, 0.145635, 12.239705], - [8.475, 0.15097, 12.199433], - [13.02, 0.071736, 12.870946], - [22.318333, 0.042305, 13.254131], - [36.463333, 0.021195, 13.725284], - [54.82, 0.001805, 14.43229] + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] ] } }, @@ -74,34 +74,34 @@ "aspirate": { "default": { "1": [ - [1.39875, 4.681865, 0.866627], - [2.5225, 2.326382, 4.161359], - [3.625, 1.361424, 6.595466], - [4.69125, 0.848354, 8.455342], - [5.705, 0.519685, 9.997214], - [6.70625, 0.36981, 10.852249], - [7.69375, 0.267029, 11.541523], - [8.67875, 0.210129, 11.979299], - [47.05, 0.030309, 13.539909], - [95.24375, 0.003774, 14.78837], - [211.0225, 0.000928, 15.059476] + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] ] } }, "dispense": { "default": { "1": [ - [1.39875, 4.681865, 0.866627], - [2.5225, 2.326382, 4.161359], - [3.625, 1.361424, 6.595466], - [4.69125, 0.848354, 8.455342], - [5.705, 0.519685, 9.997214], - [6.70625, 0.36981, 10.852249], - [7.69375, 0.267029, 11.541523], - [8.67875, 0.210129, 11.979299], - [47.05, 0.030309, 13.539909], - [95.24375, 0.003774, 14.78837], - [211.0225, 0.000928, 15.059476] + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] ] } }, @@ -126,46 +126,46 @@ "aspirate": { "default": { "1": [ - [3.76, 2.041301, 4.284751], - [5.684286, 0.49624, 10.09418], - [8.445714, 0.187358, 11.849952], - [12.981429, 0.073135, 12.814653], - [17.667143, 0.060853, 12.974083], - [46.515714, 0.025888, 13.591828], - [95.032857, 0.006561, 14.490827], - [114.488571, 0.00306, 14.823556], - [192.228571, 0.001447, 15.00822], - [309.921429, 0.000995, 15.095087], - [436.984286, 0.000322, 15.303634], - [632.861429, 0.000208, 15.353582], - [828.952857, 0.00013, 15.402544], - [976.118571, 0.000095, 15.431673], - [1005.275714, -0.000067, 15.589843], - [1024.768571, -0.000021, 15.543681], - [1049.145714, -0.000013, 15.535884] + [3.9, 1.789, 5.4283], + [5.6991, 0.3019, 11.2278], + [8.5155, 0.2111, 11.7453], + [13.1482, 0.0858, 12.8124], + [17.8909, 0.0604, 13.1472], + [46.0982, 0.0155, 13.9505], + [93.5618, 0.0046, 14.4523], + [112.5991, 0.0023, 14.6687], + [189.5555, 0.002, 14.7035], + [305.5891, 0.001, 14.887], + [431.2836, 0.0004, 15.055], + [625.0209, 0.0003, 15.1309], + [818.6909, 0.0001, 15.2112], + [963.9909, 0.0001, 15.2445], + [992.0791, -0.0005, 15.7723], + [1012.2118, 0.0007, 14.6701], + [1037.1873, 0.0005, 14.8072] ] } }, "dispense": { "default": { "1": [ - [3.76, 2.041301, 4.284751], - [5.684286, 0.49624, 10.09418], - [8.445714, 0.187358, 11.849952], - [12.981429, 0.073135, 12.814653], - [17.667143, 0.060853, 12.974083], - [46.515714, 0.025888, 13.591828], - [95.032857, 0.006561, 14.490827], - [114.488571, 0.00306, 14.823556], - [192.228571, 0.001447, 15.00822], - [309.921429, 0.000995, 15.095087], - [436.984286, 0.000322, 15.303634], - [632.861429, 0.000208, 15.353582], - [828.952857, 0.00013, 15.402544], - [976.118571, 0.000095, 15.431673], - [1005.275714, -0.000067, 15.589843], - [1024.768571, -0.000021, 15.543681], - [1049.145714, -0.000013, 15.535884] + [3.9, 1.789, 5.4283], + [5.6991, 0.3019, 11.2278], + [8.5155, 0.2111, 11.7453], + [13.1482, 0.0858, 12.8124], + [17.8909, 0.0604, 13.1472], + [46.0982, 0.0155, 13.9505], + [93.5618, 0.0046, 14.4523], + [112.5991, 0.0023, 14.6687], + [189.5555, 0.002, 14.7035], + [305.5891, 0.001, 14.887], + [431.2836, 0.0004, 15.055], + [625.0209, 0.0003, 15.1309], + [818.6909, 0.0001, 15.2112], + [963.9909, 0.0001, 15.2445], + [992.0791, -0.0005, 15.7723], + [1012.2118, 0.0007, 14.6701], + [1037.1873, 0.0005, 14.8072] ] } }, From 3382ae373feac73b69e314406a0f93228d1814fc Mon Sep 17 00:00:00 2001 From: Derek Maggio Date: Mon, 8 Apr 2024 09:31:23 -0700 Subject: [PATCH 07/49] chore: create performance metrics project (#14806) # Overview Basic setup of Performance Metrics project Closes https://opentrons.atlassian.net/browse/EXEC-380 # Test Plan - [x] Verify all makefile commands can run successfully (GH Actions will be in another PR) - [x] Build wheel, install, and verify it runs --- performance-metrics/.flake8 | 25 ++ performance-metrics/.gitignore | 1 + performance-metrics/Makefile | 28 ++ performance-metrics/Pipfile | 19 + performance-metrics/Pipfile.lock | 367 ++++++++++++++++++ performance-metrics/README.md | 3 + performance-metrics/mypy.ini | 5 + performance-metrics/pytest.ini | 3 + performance-metrics/setup.py | 91 +++++ .../src/performance_metrics/__init__.py | 1 + .../src/performance_metrics/py.typed | 0 11 files changed, 543 insertions(+) create mode 100644 performance-metrics/.flake8 create mode 100644 performance-metrics/.gitignore create mode 100644 performance-metrics/Makefile create mode 100644 performance-metrics/Pipfile create mode 100644 performance-metrics/Pipfile.lock create mode 100644 performance-metrics/README.md create mode 100644 performance-metrics/mypy.ini create mode 100644 performance-metrics/pytest.ini create mode 100755 performance-metrics/setup.py create mode 100644 performance-metrics/src/performance_metrics/__init__.py create mode 100644 performance-metrics/src/performance_metrics/py.typed diff --git a/performance-metrics/.flake8 b/performance-metrics/.flake8 new file mode 100644 index 00000000000..4aa1c02d7aa --- /dev/null +++ b/performance-metrics/.flake8 @@ -0,0 +1,25 @@ +[flake8] + +# max cyclomatic complexity +max-complexity = 9 + +extend-ignore = + # defer formatting concerns to black + # E203: space around `:` operator + # E501: maximum line length + E203, + E501, + # do not require type annotations for self nor cls + ANN101, + ANN102 + # do not require docstring for __init__, put them on the class + D107, + +# configure flake8-docstrings +# https://pypi.org/project/flake8-docstrings/ +docstring-convention = google + +noqa-require-code = true + +per-file-ignores = + setup.py:ANN,D \ No newline at end of file diff --git a/performance-metrics/.gitignore b/performance-metrics/.gitignore new file mode 100644 index 00000000000..8fb3d9a4ea5 --- /dev/null +++ b/performance-metrics/.gitignore @@ -0,0 +1 @@ +.ruff_cache/ \ No newline at end of file diff --git a/performance-metrics/Makefile b/performance-metrics/Makefile new file mode 100644 index 00000000000..cce4fd7d93a --- /dev/null +++ b/performance-metrics/Makefile @@ -0,0 +1,28 @@ +include ../scripts/python.mk + +.PHONY: lint +lint: + $(python) -m black --check . + $(python) -m flake8 . + $(python) -m mypy . + +.PHONY: format +format: + $(python) -m black . + +.PHONY: setup +setup: + $(pipenv) sync --dev + +.PHONY: teardown +teardown: + $(pipenv) --rm + +.PHONY: clean +clean: + rm -rf build dist *.egg-info .mypy_cache .pytest_cache src/performance_metrics.egg-info + +.PHONY: wheel +wheel: + $(python) setup.py $(wheel_opts) bdist_wheel + rm -rf build \ No newline at end of file diff --git a/performance-metrics/Pipfile b/performance-metrics/Pipfile new file mode 100644 index 00000000000..df5a3de89d6 --- /dev/null +++ b/performance-metrics/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +opentrons-shared-data = {file = "../shared-data/python", editable = true} + +[dev-packages] +pytest = "==7.2.2" +mypy = "==1.8.0" +flake8 = "==7.0.0" +flake8-annotations = "~=3.0.1" +flake8-docstrings = "~=1.7.0" +flake8-noqa = "~=1.4.0" +black = "==22.3.0" + +[requires] +python_version = "3.10" diff --git a/performance-metrics/Pipfile.lock b/performance-metrics/Pipfile.lock new file mode 100644 index 00000000000..61556f3dee9 --- /dev/null +++ b/performance-metrics/Pipfile.lock @@ -0,0 +1,367 @@ +{ + "_meta": { + "hash": { + "sha256": "fa95804888e2d45ce401c98bafc9b543cb6e1afe0a36713660d3f5517ac02b8e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "jsonschema": { + "hashes": [ + "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", + "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" + ], + "markers": "python_version >= '3.7'", + "version": "==4.17.3" + }, + "opentrons-shared-data": { + "editable": true, + "file": "../shared-data/python", + "markers": "python_version >= '3.8'" + }, + "pydantic": { + "hashes": [ + "sha256:005655cabc29081de8243126e036f2065bd7ea5b9dff95fde6d2c642d39755de", + "sha256:0d142fa1b8f2f0ae11ddd5e3e317dcac060b951d605fda26ca9b234b92214986", + "sha256:22ed12ee588b1df028a2aa5d66f07bf8f8b4c8579c2e96d5a9c1f96b77f3bb55", + "sha256:2746189100c646682eff0bce95efa7d2e203420d8e1c613dc0c6b4c1d9c1fde4", + "sha256:28e552a060ba2740d0d2aabe35162652c1459a0b9069fe0db7f4ee0e18e74d58", + "sha256:3287e1614393119c67bd4404f46e33ae3be3ed4cd10360b48d0a4459f420c6a3", + "sha256:3350f527bb04138f8aff932dc828f154847fbdc7a1a44c240fbfff1b57f49a12", + "sha256:3453685ccd7140715e05f2193d64030101eaad26076fad4e246c1cc97e1bb30d", + "sha256:394f08750bd8eaad714718812e7fab615f873b3cdd0b9d84e76e51ef3b50b6b7", + "sha256:4e316e54b5775d1eb59187f9290aeb38acf620e10f7fd2f776d97bb788199e53", + "sha256:50f1666a9940d3d68683c9d96e39640f709d7a72ff8702987dab1761036206bb", + "sha256:51d405b42f1b86703555797270e4970a9f9bd7953f3990142e69d1037f9d9e51", + "sha256:584f2d4c98ffec420e02305cf675857bae03c9d617fcfdc34946b1160213a948", + "sha256:5e09c19df304b8123938dc3c53d3d3be6ec74b9d7d0d80f4f4b5432ae16c2022", + "sha256:676ed48f2c5bbad835f1a8ed8a6d44c1cd5a21121116d2ac40bd1cd3619746ed", + "sha256:67f1a1fb467d3f49e1708a3f632b11c69fccb4e748a325d5a491ddc7b5d22383", + "sha256:6a51a1dd4aa7b3f1317f65493a182d3cff708385327c1c82c81e4a9d6d65b2e4", + "sha256:6bd7030c9abc80134087d8b6e7aa957e43d35714daa116aced57269a445b8f7b", + "sha256:75279d3cac98186b6ebc2597b06bcbc7244744f6b0b44a23e4ef01e5683cc0d2", + "sha256:7ac9237cd62947db00a0d16acf2f3e00d1ae9d3bd602b9c415f93e7a9fc10528", + "sha256:7ea210336b891f5ea334f8fc9f8f862b87acd5d4a0cbc9e3e208e7aa1775dabf", + "sha256:82790d4753ee5d00739d6cb5cf56bceb186d9d6ce134aca3ba7befb1eedbc2c8", + "sha256:92229f73400b80c13afcd050687f4d7e88de9234d74b27e6728aa689abcf58cc", + "sha256:9bea1f03b8d4e8e86702c918ccfd5d947ac268f0f0cc6ed71782e4b09353b26f", + "sha256:a980a77c52723b0dc56640ced396b73a024d4b74f02bcb2d21dbbac1debbe9d0", + "sha256:af9850d98fc21e5bc24ea9e35dd80a29faf6462c608728a110c0a30b595e58b7", + "sha256:bbc6989fad0c030bd70a0b6f626f98a862224bc2b1e36bfc531ea2facc0a340c", + "sha256:be51dd2c8596b25fe43c0a4a59c2bee4f18d88efb8031188f9e7ddc6b469cf44", + "sha256:c365ad9c394f9eeffcb30a82f4246c0006417f03a7c0f8315d6211f25f7cb654", + "sha256:c3d5731a120752248844676bf92f25a12f6e45425e63ce22e0849297a093b5b0", + "sha256:ca832e124eda231a60a041da4f013e3ff24949d94a01154b137fc2f2a43c3ffb", + "sha256:d207d5b87f6cbefbdb1198154292faee8017d7495a54ae58db06762004500d00", + "sha256:d31ee5b14a82c9afe2bd26aaa405293d4237d0591527d9129ce36e58f19f95c1", + "sha256:d3b5c4cbd0c9cb61bbbb19ce335e1f8ab87a811f6d589ed52b0254cf585d709c", + "sha256:d573082c6ef99336f2cb5b667b781d2f776d4af311574fb53d908517ba523c22", + "sha256:e49db944fad339b2ccb80128ffd3f8af076f9f287197a480bf1e4ca053a866f0" + ], + "markers": "python_version >= '3.7'", + "version": "==1.10.15" + }, + "pyrsistent": { + "hashes": [ + "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f", + "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", + "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", + "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34", + "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca", + "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", + "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", + "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", + "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714", + "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf", + "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", + "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", + "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", + "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d", + "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054", + "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", + "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7", + "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423", + "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce", + "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", + "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", + "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0", + "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f", + "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", + "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce", + "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", + "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", + "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86", + "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f", + "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b", + "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98", + "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version >= '3.8'", + "version": "==4.11.0" + } + }, + "develop": { + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "black": { + "hashes": [ + "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b", + "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176", + "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09", + "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a", + "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015", + "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79", + "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb", + "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20", + "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464", + "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968", + "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82", + "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21", + "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0", + "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265", + "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b", + "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a", + "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72", + "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce", + "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0", + "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a", + "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163", + "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad", + "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d" + ], + "index": "pypi", + "markers": "python_full_version >= '3.6.2'", + "version": "==22.3.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "exceptiongroup": { + "hashes": [ + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.0" + }, + "flake8": { + "hashes": [ + "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", + "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==7.0.0" + }, + "flake8-annotations": { + "hashes": [ + "sha256:af78e3216ad800d7e144745ece6df706c81b3255290cbf870e54879d495e8ade", + "sha256:ff37375e71e3b83f2a5a04d443c41e2c407de557a884f3300a7fa32f3c41cb0a" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==3.0.1" + }, + "flake8-docstrings": { + "hashes": [ + "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", + "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.7.0" + }, + "flake8-noqa": { + "hashes": [ + "sha256:4465e16a19be433980f6f563d05540e2e54797eb11facb9feb50fed60624dc45", + "sha256:771765ab27d1efd157528379acd15131147f9ae578a72d17fb432ca197881243" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.4.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy": { + "hashes": [ + "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", + "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", + "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", + "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", + "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", + "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", + "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", + "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", + "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", + "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", + "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", + "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", + "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", + "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", + "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", + "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", + "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", + "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", + "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", + "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", + "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", + "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", + "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", + "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", + "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", + "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", + "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.8.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", + "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.0" + }, + "pluggy": { + "hashes": [ + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", + "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + ], + "markers": "python_version >= '3.8'", + "version": "==2.11.1" + }, + "pydocstyle": { + "hashes": [ + "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", + "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1" + ], + "markers": "python_version >= '3.6'", + "version": "==6.3.0" + }, + "pyflakes": { + "hashes": [ + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + ], + "markers": "python_version >= '3.8'", + "version": "==3.2.0" + }, + "pytest": { + "hashes": [ + "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", + "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==7.2.2" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version >= '3.8'", + "version": "==4.11.0" + } + } +} diff --git a/performance-metrics/README.md b/performance-metrics/README.md new file mode 100644 index 00000000000..7fb20445e36 --- /dev/null +++ b/performance-metrics/README.md @@ -0,0 +1,3 @@ +# Performance Metrics + +Project to gather various performance metrics for the Opentrons Flex. diff --git a/performance-metrics/mypy.ini b/performance-metrics/mypy.ini new file mode 100644 index 00000000000..b94476cbcaa --- /dev/null +++ b/performance-metrics/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +show_error_codes = True +warn_unused_configs = True +strict = True +exclude = setup.py \ No newline at end of file diff --git a/performance-metrics/pytest.ini b/performance-metrics/pytest.ini new file mode 100644 index 00000000000..49f04412746 --- /dev/null +++ b/performance-metrics/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --color=yes --strict-markers +asyncio_mode = auto diff --git a/performance-metrics/setup.py b/performance-metrics/setup.py new file mode 100755 index 00000000000..eced9a55ab9 --- /dev/null +++ b/performance-metrics/setup.py @@ -0,0 +1,91 @@ +# Inspired by: +# https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/ +import sys +import codecs +import os +import os.path +from setuptools import setup, find_packages + +# make stdout blocking since Travis sets it to nonblocking +if os.name == "posix": + import fcntl + + flags = fcntl.fcntl(sys.stdout, fcntl.F_GETFL) + fcntl.fcntl(sys.stdout, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + +HERE = os.path.abspath(os.path.dirname(__file__)) +sys.path.append(os.path.join(HERE, "..", "scripts")) + +from python_build_utils import normalize_version # noqa: E402 + + +def get_version(): + buildno = os.getenv("BUILD_NUMBER") + project = os.getenv("OPENTRONS_PROJECT", "robot-stack") + git_dir = os.getenv("OPENTRONS_GIT_DIR", None) + if buildno: + normalize_opts = {"extra_tag": buildno} + else: + normalize_opts = {} + return normalize_version( + "performance-metrics", project, git_dir=git_dir, **normalize_opts + ) + + +VERSION = get_version() + +DISTNAME = "performance_metrics" +LICENSE = "Apache 2.0" +AUTHOR = "Opentrons" +EMAIL = "engineering@opentrons.com" +URL = "https://github.com/Opentrons/opentrons" +DOWNLOAD_URL = "" +CLASSIFIERS = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "Intended Audience :: Science/Research", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", +] +KEYWORDS = ["robots", "protocols", "synbio", "pcr", "automation", "lab"] +DESCRIPTION = "Library for working with performance metrics on the Opentrons robots" +PACKAGES = find_packages(where="src", exclude=["tests.*", "tests"]) +INSTALL_REQUIRES = [ + f"opentrons-shared-data=={VERSION}", +] + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() + + +if __name__ == "__main__": + setup( + python_requires="~=3.10", + name=DISTNAME, + description=DESCRIPTION, + license=LICENSE, + url=URL, + version=VERSION, + author=AUTHOR, + author_email=EMAIL, + maintainer=AUTHOR, + maintainer_email=EMAIL, + keywords=KEYWORDS, + long_description=__doc__, + packages=PACKAGES, + zip_safe=False, + classifiers=CLASSIFIERS, + install_requires=INSTALL_REQUIRES, + include_package_data=True, + package_dir={"": "src"}, + package_data={"performance-metrics": ["py.typed"]}, + ) diff --git a/performance-metrics/src/performance_metrics/__init__.py b/performance-metrics/src/performance_metrics/__init__.py new file mode 100644 index 00000000000..a92b39b6d7b --- /dev/null +++ b/performance-metrics/src/performance_metrics/__init__.py @@ -0,0 +1 @@ +"""Opentrons performance metrics library.""" diff --git a/performance-metrics/src/performance_metrics/py.typed b/performance-metrics/src/performance_metrics/py.typed new file mode 100644 index 00000000000..e69de29bb2d From d8defe546b4e4d6203572911415efc8adfcf9e9c Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 8 Apr 2024 12:48:19 -0400 Subject: [PATCH 08/49] fix(shared-data, components, app): fix runtime parameter min-max range for float (#14833) * fix(shared-data, components, app): fix runtime parameter min-max range for float --- .../__tests__/ProtocolParameters.test.tsx | 2 +- app/src/pages/ProtocolDetails/Parameters.tsx | 10 +++-- .../__tests__/ParametersTable.test.tsx | 2 +- .../src/molecules/ParametersTable/index.tsx | 17 ++++----- .../formatRunTimeParameterMinMax.test.tsx | 37 +++++++++++++++++++ .../helpers/formatRunTimeParameterMinMax.ts | 11 ++++++ shared-data/js/helpers/index.ts | 1 + 7 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx create mode 100644 shared-data/js/helpers/formatRunTimeParameterMinMax.ts diff --git a/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx b/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx index 173a03f0c7a..191329bbae8 100644 --- a/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx +++ b/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx @@ -122,7 +122,7 @@ describe('ProtocolParameters', () => { screen.getByText('EtoH Volume') screen.getByText('6.5 mL') - screen.getByText('1.5-10') + screen.getByText('1.5-10.0') screen.getByText('Default Module Offsets') screen.getByText('No offsets') diff --git a/app/src/pages/ProtocolDetails/Parameters.tsx b/app/src/pages/ProtocolDetails/Parameters.tsx index b8cbfa71155..b908b5b84d7 100644 --- a/app/src/pages/ProtocolDetails/Parameters.tsx +++ b/app/src/pages/ProtocolDetails/Parameters.tsx @@ -1,7 +1,10 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { formatRunTimeParameterDefaultValue } from '@opentrons/shared-data' +import { + formatRunTimeParameterDefaultValue, + formatRunTimeParameterMinMax, +} from '@opentrons/shared-data' import { BORDERS, COLORS, @@ -61,9 +64,8 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { const getRange = (parameter: RunTimeParameter): string => { const { type } = parameter - const min = 'min' in parameter ? parameter.min : 0 - const max = 'max' in parameter ? parameter.max : 0 const numChoices = 'choices' in parameter ? parameter.choices.length : 0 + const minMax = formatRunTimeParameterMinMax(parameter) let range: string | null = null if (numChoices === 2 && 'choices' in parameter) { range = `${parameter.choices[0].displayName}, ${parameter.choices[1].displayName}` @@ -75,7 +77,7 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { } case 'float': case 'int': { - return `${min}-${max}` + return minMax } case 'str': { return range ?? t('num_choices', { num: numChoices }) diff --git a/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx b/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx index aee232ebf8c..5cd4b59a59b 100644 --- a/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx +++ b/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx @@ -98,7 +98,7 @@ describe('ParametersTable', () => { screen.getByText('EtoH Volume') screen.getByText('6.5 mL') - screen.getByText('1.5-10') + screen.getByText('1.5-10.0') // more than 2 options screen.getByText('Default Module Offsets') diff --git a/components/src/molecules/ParametersTable/index.tsx b/components/src/molecules/ParametersTable/index.tsx index 4ca8d8a2cb0..485a5efc6e5 100644 --- a/components/src/molecules/ParametersTable/index.tsx +++ b/components/src/molecules/ParametersTable/index.tsx @@ -1,6 +1,9 @@ import * as React from 'react' import styled, { css } from 'styled-components' -import { formatRunTimeParameterDefaultValue } from '@opentrons/shared-data' +import { + formatRunTimeParameterDefaultValue, + formatRunTimeParameterMinMax, +} from '@opentrons/shared-data' import { BORDERS, COLORS } from '../../helix-design-system' import { SPACING, TYPOGRAPHY } from '../../ui-style-constants/index' import { StyledText } from '../../atoms/StyledText' @@ -23,11 +26,9 @@ export function ParametersTable({ runTimeParameters, t, }: ProtocolParameterItemsProps): JSX.Element { - const formatRange = ( - runTimeParameter: RunTimeParameter, - minMax: string - ): string => { + const formatRange = (runTimeParameter: RunTimeParameter): string => { const { type } = runTimeParameter + const minMax = formatRunTimeParameterMinMax(runTimeParameter) const choices = 'choices' in runTimeParameter ? runTimeParameter.choices : [] const count = choices.length @@ -64,8 +65,6 @@ export function ParametersTable({ {runTimeParameters.map((parameter: RunTimeParameter, index: number) => { - const min = 'min' in parameter ? parameter.min : 0 - const max = 'max' in parameter ? parameter.max : 0 return ( - - {formatRange(parameter, `${min}-${max}`)} - + {formatRange(parameter)} ) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx b/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx new file mode 100644 index 00000000000..07190fac23e --- /dev/null +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { formatRunTimeParameterMinMax } from '../formatRunTimeParameterMinMax' + +import type { RunTimeParameter } from '../../types' + +describe('utils-formatRunTimeParameterMinMax', () => { + it('should return int min and max', () => { + const mockData = { + value: 6, + displayName: 'PCR Cycles', + variableName: 'PCR_CYCLES', + description: 'number of PCR cycles on a thermocycler', + type: 'int', + min: 1, + max: 10, + default: 6, + } as RunTimeParameter + const result = formatRunTimeParameterMinMax(mockData) + expect(result).toEqual('1-10') + }) + + it('should return value with suffix when type is float', () => { + const mockData = { + value: 6.5, + displayName: 'EtoH Volume', + variableName: 'ETOH_VOLUME', + description: '70% ethanol volume', + type: 'float', + suffix: 'mL', + min: 1.5, + max: 10.0, + default: 6.5, + } as RunTimeParameter + const result = formatRunTimeParameterMinMax(mockData) + expect(result).toEqual('1.5-10.0') + }) +}) diff --git a/shared-data/js/helpers/formatRunTimeParameterMinMax.ts b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts new file mode 100644 index 00000000000..36444f89601 --- /dev/null +++ b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts @@ -0,0 +1,11 @@ +import type { RunTimeParameter } from '../types' + +export const formatRunTimeParameterMinMax = ( + runTimeParameter: RunTimeParameter +): string => { + const min = 'min' in runTimeParameter ? runTimeParameter.min : 0 + const max = 'max' in runTimeParameter ? runTimeParameter.max : 0 + return runTimeParameter.type === 'int' + ? `${min}-${max}` + : `${min.toFixed(1)}-${max.toFixed(1)}` +} diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index b996606f6e8..854b82d5133 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -30,6 +30,7 @@ export * from './getAddressableAreasInProtocol' export * from './getSimplestFlexDeckConfig' export * from './formatRunTimeParameterDefaultValue' export * from './formatRunTimeParameterValue' +export * from './formatRunTimeParameterMinMax' export const getLabwareDefIsStandard = (def: LabwareDefinition2): boolean => def?.namespace === OPENTRONS_LABWARE_NAMESPACE From 3385bf1d64d6c1ff44e809c70bbc0660c170274e Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 8 Apr 2024 13:00:54 -0400 Subject: [PATCH 09/49] refactor(components): update parameter table stories (#14815) * refactor(components): update parameter table stories --- components/src/atoms/Chip/Chip.stories.tsx | 6 +-- .../ParametersTable.stories.tsx | 40 ++++++++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/components/src/atoms/Chip/Chip.stories.tsx b/components/src/atoms/Chip/Chip.stories.tsx index 2868d7246f7..027ea4cbdbe 100644 --- a/components/src/atoms/Chip/Chip.stories.tsx +++ b/components/src/atoms/Chip/Chip.stories.tsx @@ -14,27 +14,23 @@ const meta: Meta = { control: { type: 'select', }, - defaultValue: 'basic', }, hasIcon: { control: { type: 'boolean', }, - defaultValue: true, }, chipSize: { options: ['medium', 'small'], control: { type: 'select', }, - defaultValue: 'medium', }, iconName: { options: ['connection-status', 'ot-check', 'ot-alert'], control: { type: 'select', }, - defaultValue: 'ot-alert', }, }, component: Chip, @@ -57,7 +53,7 @@ type Story = StoryObj export const ChipComponent: Story = { args: { - type: 'basic', + type: 'success', text: 'Chip component', hasIcon: true, chipSize: 'medium', diff --git a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx index 93ba92cfdd4..d68e2f80a95 100644 --- a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx +++ b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx @@ -1,15 +1,10 @@ -import * as React from 'react' -import { ParametersTable } from '@opentrons/components' -import type { Story, Meta } from '@storybook/react' -import type { RunTimeParameter } from '@opentrons/shared-data' - -export default { - title: 'Library/Molecules/ParametersTable', -} as Meta +import * as React from 'react-remove-scroll' +import { Flex } from '../../primitives' +import { SPACING } from '../../ui-style-constants' +import { ParametersTable } from './index' -const Template: Story> = args => ( - -) +import type { Meta, StoryObj } from '@storybook/react' +import type { RunTimeParameter } from '@opentrons/shared-data' const runTimeParameters: RunTimeParameter[] = [ { @@ -153,7 +148,24 @@ const runTimeParameters: RunTimeParameter[] = [ default: 'flex', }, ] -export const Default = Template.bind({}) -Default.args = { - runTimeParameters: runTimeParameters, + +const meta: Meta = { + title: 'Library/Molecules/ParametersTable', + component: ParametersTable, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta + +type Story = StoryObj + +export const DefaultParameterTable: Story = { + args: { + runTimeParameters: runTimeParameters, + }, } From 88c3f2c3261c5bfa25ec24842e695106968b95ae Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 8 Apr 2024 13:01:13 -0400 Subject: [PATCH 10/49] refactor(components): update Box stories (#14827) * refactor(components): update Box stories --- components/src/primitives/Box.stories.tsx | 34 +++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/components/src/primitives/Box.stories.tsx b/components/src/primitives/Box.stories.tsx index 3d322842a0a..54fd773d125 100644 --- a/components/src/primitives/Box.stories.tsx +++ b/components/src/primitives/Box.stories.tsx @@ -1,21 +1,25 @@ -import * as React from 'react' +import { COLORS, BORDERS } from '../helix-design-system' +import { SPACING } from '../ui-style-constants' import { Box as BoxComponent } from './Box' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Atoms/Box', -} as Meta + component: BoxComponent, +} + +export default meta + +type Story = StoryObj -const Template: Story> = args => ( - -) -export const Box = Template.bind({}) -Box.args = { - children: - 'This is a simple box atom that accepts all primitive styling props.', - backgroundColor: 'grey', - border: '1px solid black', - padding: '1rem', - maxWidth: '20rem', +export const Box: Story = { + args: { + children: + 'This is a simple box atom that accepts all primitive styling props.', + backgroundColor: COLORS.grey60, + border: `1px ${BORDERS.styleSolid} black`, + padding: SPACING.spacing16, + maxWidth: '20rem', + }, } From 1a5052cbb338d0f42e0587f132009a5892481f41 Mon Sep 17 00:00:00 2001 From: Derek Maggio Date: Mon, 8 Apr 2024 12:43:41 -0700 Subject: [PATCH 11/49] Exec 372 hide performance metrics project behind ff (#14811) # Overview Add feature flag for Performance Metrics project. Closes https://opentrons.atlassian.net/browse/EXEC-372 # Changelog - Add enablePerformanceMetrics feature flag in advanced settings - Add migration function - Update tests --- api/src/opentrons/config/advanced_settings.py | 22 ++++++++++++++++++ api/src/opentrons/config/feature_flags.py | 4 ++++ .../config/test_advanced_settings.py | 23 +++++++++++++------ .../test_advanced_settings_migration.py | 17 +++++++++++++- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/api/src/opentrons/config/advanced_settings.py b/api/src/opentrons/config/advanced_settings.py index 191c0d69ccc..f4c75701901 100644 --- a/api/src/opentrons/config/advanced_settings.py +++ b/api/src/opentrons/config/advanced_settings.py @@ -240,6 +240,17 @@ class Setting(NamedTuple): robot_type=[RobotTypeEnum.FLEX], internal_only=True, ), + SettingDefinition( + _id="enablePerformanceMetrics", + title="Enable performance metrics", + description=( + "Do not enable." + " This is an Opentrons internal setting to collect performance metrics." + " Do not turn this on unless you are playing with the performance metrics system." + ), + robot_type=[RobotTypeEnum.OT2, RobotTypeEnum.FLEX], + internal_only=True, + ), ] if ( @@ -709,6 +720,16 @@ def _migrate31to32(previous: SettingsMap) -> SettingsMap: return newmap +def _migrate32to33(previous: SettingsMap) -> SettingsMap: + """Migrate to version 33 of the feature flags file. + + - Adds the enablePerformanceMetrics config element. + """ + newmap = {k: v for k, v in previous.items()} + newmap["enablePerformanceMetrics"] = None + return newmap + + _MIGRATIONS = [ _migrate0to1, _migrate1to2, @@ -742,6 +763,7 @@ def _migrate31to32(previous: SettingsMap) -> SettingsMap: _migrate29to30, _migrate30to31, _migrate31to32, + _migrate32to33, ] """ List of all migrations to apply, indexed by (version - 1). See _migrate below diff --git a/api/src/opentrons/config/feature_flags.py b/api/src/opentrons/config/feature_flags.py index 4a1161a2391..e9772a01ee8 100644 --- a/api/src/opentrons/config/feature_flags.py +++ b/api/src/opentrons/config/feature_flags.py @@ -76,3 +76,7 @@ def enable_error_recovery_experiments() -> bool: return advs.get_setting_with_env_overload( "enableErrorRecoveryExperiments", RobotTypeEnum.FLEX ) + + +def enable_performance_metrics(robot_type: RobotTypeEnum) -> bool: + return advs.get_setting_with_env_overload("enablePerformanceMetrics", robot_type) diff --git a/api/tests/opentrons/config/test_advanced_settings.py b/api/tests/opentrons/config/test_advanced_settings.py index b81b9149c67..17122fca0dd 100644 --- a/api/tests/opentrons/config/test_advanced_settings.py +++ b/api/tests/opentrons/config/test_advanced_settings.py @@ -34,6 +34,15 @@ def mock_settings_values_flex() -> Dict[str, Optional[bool]]: } +@pytest.fixture +def mock_settings_values_flex_all() -> Dict[str, Optional[bool]]: + return { + s.id: False + for s in advanced_settings.settings + if RobotTypeEnum.FLEX in s.robot_type + } + + @pytest.fixture def mock_settings_values_empty() -> Dict[str, Optional[bool]]: return {s.id: None for s in advanced_settings.settings} @@ -57,12 +66,12 @@ def mock_settings( @pytest.fixture def mock_read_settings_file_ot2( - mock_settings_values_ot2: Dict[str, Optional[bool]], + mock_settings_values_ot2_all: Dict[str, Optional[bool]], mock_settings_version: int, ) -> Generator[MagicMock, None, None]: with patch("opentrons.config.advanced_settings._read_settings_file") as p: p.return_value = advanced_settings.SettingsData( - settings_map=mock_settings_values_ot2, + settings_map=mock_settings_values_ot2_all, version=mock_settings_version, ) yield p @@ -70,12 +79,12 @@ def mock_read_settings_file_ot2( @pytest.fixture def mock_read_settings_file_flex( - mock_settings_values_flex: Dict[str, Optional[bool]], + mock_settings_values_flex_all: Dict[str, Optional[bool]], mock_settings_version: int, ) -> Generator[MagicMock, None, None]: with patch("opentrons.config.advanced_settings._read_settings_file") as p: p.return_value = advanced_settings.SettingsData( - settings_map=mock_settings_values_flex, + settings_map=mock_settings_values_flex_all, version=mock_settings_version, ) yield p @@ -168,19 +177,19 @@ def test_get_all_adv_settings_empty( async def test_set_adv_setting( mock_read_settings_file_ot2: MagicMock, - mock_settings_values_ot2: MagicMock, + mock_settings_values_ot2_all: MagicMock, mock_write_settings_file: MagicMock, mock_settings_version: int, restore_restart_required: None, ) -> None: - for k, v in mock_settings_values_ot2.items(): + for k, v in mock_settings_values_ot2_all.items(): # Toggle the advanced setting await advanced_settings.set_adv_setting(k, not v) mock_write_settings_file.assert_called_with( # Only the current key is toggled { nk: nv if nk != k else not v - for nk, nv in mock_settings_values_ot2.items() + for nk, nv in mock_settings_values_ot2_all.items() }, mock_settings_version, CONFIG["feature_flags_file"], diff --git a/api/tests/opentrons/config/test_advanced_settings_migration.py b/api/tests/opentrons/config/test_advanced_settings_migration.py index e1c3f51b651..e3269433db5 100644 --- a/api/tests/opentrons/config/test_advanced_settings_migration.py +++ b/api/tests/opentrons/config/test_advanced_settings_migration.py @@ -8,7 +8,7 @@ @pytest.fixture def migrated_file_version() -> int: - return 32 + return 33 # make sure to set a boolean value in default_file_settings only if @@ -31,6 +31,7 @@ def default_file_settings() -> Dict[str, Any]: "estopNotRequired": None, "enableErrorRecoveryExperiments": None, "enableOEMMode": None, + "enablePerformanceMetrics": None, } @@ -392,6 +393,18 @@ def v32_config(v31_config: Dict[str, Any]) -> Dict[str, Any]: return r +@pytest.fixture +def v33_config(v32_config: Dict[str, Any]) -> Dict[str, Any]: + r = v32_config.copy() + r.update( + { + "_version": 33, + "enablePerformanceMetrics": None, + } + ) + return r + + @pytest.fixture( scope="session", params=[ @@ -429,6 +442,7 @@ def v32_config(v31_config: Dict[str, Any]) -> Dict[str, Any]: lazy_fixture("v30_config"), lazy_fixture("v31_config"), lazy_fixture("v32_config"), + lazy_fixture("v33_config"), ], ) def old_settings(request: SubRequest) -> Dict[str, Any]: @@ -522,4 +536,5 @@ def test_ensures_config() -> None: "disableOverpressureDetection": None, "enableErrorRecoveryExperiments": None, "enableOEMMode": None, + "enablePerformanceMetrics": None, } From e620a8cf40ce5a84577005178ec5ecd61b23bbed Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 8 Apr 2024 15:58:47 -0400 Subject: [PATCH 12/49] refactor(app): remove RTP feature flag (#14837) * refactor(app): remove RTP feature flag --- .../assets/localization/en/app_settings.json | 1 - .../__tests__/ChooseProtocolSlideout.test.tsx | 50 ++++++++++++++++--- .../ChooseProtocolSlideout/index.tsx | 11 ++-- .../organisms/ChooseRobotSlideout/index.tsx | 4 +- .../index.tsx | 5 +- app/src/organisms/ProtocolDetails/index.tsx | 10 ++-- app/src/organisms/RunTimeControl/hooks.ts | 9 +--- .../Devices/ProtocolRunDetails/index.tsx | 7 +-- app/src/pages/ProtocolDetails/index.tsx | 12 ++--- app/src/pages/ProtocolSetup/index.tsx | 29 +++++------ app/src/redux/config/constants.ts | 1 - app/src/redux/config/schema-types.ts | 1 - .../protocol-storage/__fixtures__/index.ts | 2 +- 13 files changed, 73 insertions(+), 69 deletions(-) diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 4a00283f3de..18e3eef9e8a 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -1,6 +1,5 @@ { "__dev_internal__protocolStats": "Protocol Stats", - "__dev_internal__enableRunTimeParameters": "Enable Run Time Parameters", "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", "add_folder_button": "Add labware source folder", diff --git a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index d5b910381bd..11583264b3e 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -58,6 +58,7 @@ describe('ChooseProtocolSlideout', () => { screen.getByText(/choose protocol to run/i) screen.getByText(/opentrons-robot-name/i) }) + it('renders an available protocol option for every stored protocol if any', () => { render({ robot: mockConnectableRobot, @@ -70,6 +71,7 @@ describe('ChooseProtocolSlideout', () => { screen.queryByRole('heading', { name: 'No protocols found' }) ).toBeNull() }) + it('renders an empty state if no protocol options', () => { vi.mocked(getStoredProtocols).mockReturnValue([]) render({ @@ -83,22 +85,55 @@ describe('ChooseProtocolSlideout', () => { screen.getByRole('heading', { name: 'No protocols found' }) ).toBeInTheDocument() }) - it('calls createRunFromProtocolSource if CTA clicked', () => { + + // it('calls createRunFromProtocolSource if CTA clicked', () => { + // const protocolDataWithoutRunTimeParameter = { + // ...storedProtocolDataFixture, + // runTimeParameters: [], + // } + // vi.mocked(getStoredProtocols).mockReturnValue([ + // protocolDataWithoutRunTimeParameter, + // ]) + // render({ + // robot: mockConnectableRobot, + // onCloseClick: vi.fn(), + // showSlideout: true, + // }) + // const proceedButton = screen.getByRole('button', { + // name: 'Proceed to setup', + // }) + // fireEvent.click(proceedButton) + // expect(mockCreateRunFromProtocol).toHaveBeenCalledWith({ + // files: [expect.any(File)], + // protocolKey: storedProtocolDataFixture.protocolKey, + // }) + // expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() + // }) + + it('move to the second slideout if CTA clicked', () => { + const protocolDataWithoutRunTimeParameter = { + ...storedProtocolDataFixture, + runTimeParameters: [], + } + vi.mocked(getStoredProtocols).mockReturnValue([ + protocolDataWithoutRunTimeParameter, + ]) render({ robot: mockConnectableRobot, onCloseClick: vi.fn(), showSlideout: true, }) const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) fireEvent.click(proceedButton) - expect(mockCreateRunFromProtocol).toHaveBeenCalledWith({ - files: [expect.any(File)], - protocolKey: storedProtocolDataFixture.protocolKey, - }) - expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() + screen.getByText('Step 2 / 2') + screen.getByText('number of samples') + screen.getByText('Restore default values') }) + + // ToDo (kk:04/08) update test for RTP + /* it('renders error state when there is a run creation error', () => { vi.mocked(useCreateRunFromProtocol).mockReturnValue({ runCreationError: 'run creation error', @@ -153,4 +188,5 @@ describe('ChooseProtocolSlideout', () => { fireEvent.click(link) expect(link.getAttribute('href')).toEqual('/devices/opentrons-robot-name') }) + */ }) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index b2d48540ae8..fd9085e07cb 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import first from 'lodash/first' import { Trans, useTranslation } from 'react-i18next' import { Link, NavLink, useHistory } from 'react-router-dom' -import { ApiHostProvider } from '@opentrons/react-api-client' import { useSelector } from 'react-redux' import { css } from 'styled-components' @@ -14,7 +13,6 @@ import { DIRECTION_COLUMN, DIRECTION_ROW, DISPLAY_BLOCK, - DropdownOption, Flex, Icon, Link as LinkComponent, @@ -30,12 +28,12 @@ import { TYPOGRAPHY, useHoverTooltip, } from '@opentrons/components' +import { ApiHostProvider } from '@opentrons/react-api-client' import { useLogger } from '../../logger' import { OPENTRONS_USB } from '../../redux/discovery' import { getStoredProtocols } from '../../redux/protocol-storage' import { appShellRequestor } from '../../redux/shell/remote' -import { useFeatureFlag } from '../../redux/config' import { MultiSlideout } from '../../atoms/Slideout/MultiSlideout' import { Tooltip } from '../../atoms/Tooltip' import { ToggleButton } from '../../atoms/buttons' @@ -47,8 +45,10 @@ import { useCreateRunFromProtocol } from '../ChooseRobotToRunProtocolSlideout/us import { ApplyHistoricOffsets } from '../ApplyHistoricOffsets' import { useOffsetCandidatesForAnalysis } from '../ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { getAnalysisStatus } from '../ProtocolsLanding/utils' + import type { RunTimeParameterCreateData } from '@opentrons/api-client' import type { RunTimeParameter } from '@opentrons/shared-data' +import type { DropdownOption } from '@opentrons/components' import type { Robot } from '../../redux/discovery/types' import type { StoredProtocolData } from '../../redux/protocol-storage' import type { State } from '../../redux/types' @@ -93,7 +93,6 @@ export function ChooseProtocolSlideoutComponent( ] = React.useState([]) const [currentPage, setCurrentPage] = React.useState(1) const [hasParamError, setHasParamError] = React.useState(false) - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') React.useEffect(() => { setRunTimeParametersOverrides( @@ -106,9 +105,9 @@ export function ChooseProtocolSlideoutComponent( const runTimeParametersFromAnalysis = selectedProtocol?.mostRecentAnalysis?.runTimeParameters ?? [] + console.log('runTimeParametersFromAnalysis', runTimeParametersFromAnalysis) - const hasRunTimeParameters = - enableRunTimeParametersFF && runTimeParametersFromAnalysis.length > 0 + const hasRunTimeParameters = runTimeParametersFromAnalysis.length > 0 const analysisStatus = getAnalysisStatus( false, diff --git a/app/src/organisms/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index 904615b9ca5..d19a62a514d 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -51,7 +51,6 @@ import type { SlideoutProps } from '../../atoms/Slideout' import type { UseCreateRun } from '../../organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol' import type { State, Dispatch } from '../../redux/types' import type { Robot } from '../../redux/discovery/types' -import { useFeatureFlag } from '../../redux/config' import type { DropdownOption } from '../../atoms/MenuList/DropdownMenu' export const CARD_OUTLINE_BORDER_STYLE = css` @@ -142,7 +141,6 @@ export function ChooseRobotSlideout( setHasParamError, } = props - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') const dispatch = useDispatch() const isScanning = useSelector((state: State) => getScanning(state)) const [targetProps, tooltipProps] = useHoverTooltip() @@ -526,7 +524,7 @@ export function ChooseRobotSlideout( ) : null - return multiSlideout != null && enableRunTimeParametersFF ? ( + return multiSlideout != null ? ( (1) const [selectedRobot, setSelectedRobot] = React.useState(null) const { trackCreateProtocolRunEvent } = useTrackCreateProtocolRunEvent( @@ -176,8 +174,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( ) - const hasRunTimeParameters = - enableRunTimeParametersFF && runTimeParameters.length > 0 + const hasRunTimeParameters = runTimeParameters.length > 0 return ( 0 + const hasRunTimeParameters = runTimeParameters.length > 0 const [currentTab, setCurrentTab] = React.useState< 'robot_config' | 'labware' | 'liquids' | 'stats' | 'parameters' >(hasRunTimeParameters ? 'parameters' : 'robot_config') @@ -333,9 +331,7 @@ export function ProtocolDetails( stats: enableProtocolStats ? ( ) : null, - parameters: enableRunTimeParameters ? ( - - ) : null, + parameters: , } const deckMap = @@ -596,7 +592,7 @@ export function ProtocolDetails( gridGap={SPACING.spacing8} > - {enableRunTimeParameters && mostRecentAnalysis != null && ( + {mostRecentAnalysis != null && ( (null) const listRef = React.useRef(null) const [jumpedIndex, setJumpedIndex] = React.useState(null) - const enableRunTimeParameters = useFeatureFlag('enableRunTimeParameters') + React.useEffect(() => { if (jumpedIndex != null) { setTimeout(() => setJumpedIndex(null), JUMPED_STEP_HIGHLIGHT_DELAY_MS) @@ -236,9 +235,7 @@ function PageContents(props: PageContentsProps): JSX.Element { /> - {enableRunTimeParameters ? ( - - ) : null} + diff --git a/app/src/pages/ProtocolDetails/index.tsx b/app/src/pages/ProtocolDetails/index.tsx index e44e3f7015b..0503c0eae54 100644 --- a/app/src/pages/ProtocolDetails/index.tsx +++ b/app/src/pages/ProtocolDetails/index.tsx @@ -44,7 +44,6 @@ import { getApplyHistoricOffsets, getPinnedProtocolIds, updateConfigValue, - useFeatureFlag, } from '../../redux/config' import { useOffsetCandidatesForAnalysis } from '../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { @@ -189,10 +188,8 @@ const ProtocolSectionTabs = ({ currentOption, setCurrentOption, }: ProtocolSectionTabsProps): JSX.Element => { - const enableRtpFF = useFeatureFlag('enableRunTimeParameters') - const options = enableRtpFF - ? protocolSectionTabOptions - : protocolSectionTabOptionsWithoutParameters + const options = protocolSectionTabOptions + return ( {options.map(option => { @@ -308,7 +305,6 @@ export function ProtocolDetails(): JSX.Element | null { 'protocol_info', 'shared', ]) - const enableRtpFF = useFeatureFlag('enableRunTimeParameters') const { protocolId } = useParams() const { missingProtocolHardware, @@ -326,9 +322,7 @@ export function ProtocolDetails(): JSX.Element | null { const [showParameters, setShowParameters] = React.useState(false) const queryClient = useQueryClient() const [currentOption, setCurrentOption] = React.useState( - enableRtpFF - ? protocolSectionTabOptions[0] - : protocolSectionTabOptionsWithoutParameters[0] + protocolSectionTabOptions[0] ) const [showMaxPinsAlert, setShowMaxPinsAlert] = React.useState(false) diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index be90fcfa80e..f2fb24feaa5 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -82,7 +82,7 @@ import { ANALYTICS_PROTOCOL_RUN_START, useTrackEvent, } from '../../redux/analytics' -import { getIsHeaterShakerAttached, useFeatureFlag } from '../../redux/config' +import { getIsHeaterShakerAttached } from '../../redux/config' import { ConfirmAttachedModal } from './ConfirmAttachedModal' import { getLatestCurrentOffsets } from '../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' import { CloseButton, PlayButton } from './Buttons' @@ -257,7 +257,6 @@ function PrepareToRun({ const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const history = useHistory() const { makeSnackbar } = useToaster() - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') const hasRunTimeParameters = useProtocolHasRunTimeParameters(runId) // Watch for scrolling to toggle dropshadow const scrollRef = React.useRef(null) @@ -730,20 +729,18 @@ function PrepareToRun({ disabled={lpcDisabledReason != null} disabledReason={lpcDisabledReason} /> - {enableRunTimeParametersFF ? ( - setSetupScreen('view only parameters')} - title={t('parameters')} - detail={t( - hasRunTimeParameters - ? parametersDetail - : t('no_parameters_specified') - )} - subDetail={null} - status="general" - disabled={!hasRunTimeParameters} - /> - ) : null} + setSetupScreen('view only parameters')} + title={t('parameters')} + detail={t( + hasRunTimeParameters + ? parametersDetail + : t('no_parameters_specified') + )} + subDetail={null} + status="general" + disabled={!hasRunTimeParameters} + /> setSetupScreen('labware')} title={t('labware')} diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 1dc64fea2f4..5a72622f98e 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -2,7 +2,6 @@ import type { DevInternalFlag } from './types' export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'protocolStats', - 'enableRunTimeParameters', 'enableRunNotes', 'enableQuickTransfer', ] diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index e69186f5f07..5728a2e4eb1 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -9,7 +9,6 @@ export type DiscoveryCandidates = string[] export type DevInternalFlag = | 'protocolStats' - | 'enableRunTimeParameters' | 'enableRunNotes' | 'enableQuickTransfer' diff --git a/app/src/redux/protocol-storage/__fixtures__/index.ts b/app/src/redux/protocol-storage/__fixtures__/index.ts index 12e350efb38..56f7f4d021a 100644 --- a/app/src/redux/protocol-storage/__fixtures__/index.ts +++ b/app/src/redux/protocol-storage/__fixtures__/index.ts @@ -1,5 +1,5 @@ import { simpleAnalysisFileFixture } from '@opentrons/api-client' -import { StoredProtocolData, StoredProtocolDir } from '../types' +import type { StoredProtocolData, StoredProtocolDir } from '../types' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' From 2a717d79e85ff00b538918d31306dc3554664519 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Mon, 8 Apr 2024 16:15:39 -0400 Subject: [PATCH 13/49] feat(protocol-designer): update unused module alert to account for MoaM (#14839) closes AUTH-23 --- .../components/FileSidebar/FileSidebar.tsx | 3 + .../__tests__/FileSidebar.test.tsx | 126 +++++++++++++++--- .../src/localization/en/alert.json | 8 +- 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index 3049f036b4a..e05a80e3163 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -129,6 +129,7 @@ function getWarningContent({ const pipettesDetails = pipettesWithoutStep .map(pipette => `${pipette.mount} ${pipette.spec.displayName}`) .join(' and ') + const modulesDetails = modulesWithoutStep .map(moduleOnDeck => t(`modules:module_long_names.${moduleOnDeck.type}`)) .join(' and ') @@ -169,12 +170,14 @@ function getWarningContent({ if (modulesWithoutStep.length) { const moduleCase = modulesWithoutStep.length > 1 ? 'unused_modules' : 'unused_module' + const slotName = modulesWithoutStep.map(module => module.slot) return { content: ( <>

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

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

diff --git a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx index ebe86be63a7..a9d2978b981 100644 --- a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx +++ b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' import { fireEvent, screen, cleanup } from '@testing-library/react' -import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { + FLEX_ROBOT_TYPE, + LabwareDefinition2, + fixtureTiprack300ul, +} from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { createFile, getRobotType } from '../../../file-data/selectors' import { @@ -17,11 +21,8 @@ import { import { toggleNewProtocolModal } from '../../../navigation/actions' import { getHasUnsavedChanges } from '../../../load-file/selectors' import { useBlockingHint } from '../../Hints/useBlockingHint' -import { - getUnusedEntities, - getUnusedStagingAreas, - getUnusedTrash, -} from '../utils' +import { getUnusedStagingAreas } from '../utils/getUnusedStagingAreas' +import { getUnusedTrash } from '../utils/getUnusedTrash' import { FileSidebar } from '../FileSidebar' vi.mock('../../../step-forms/selectors') @@ -30,15 +31,14 @@ vi.mock('../../../navigation/actions') vi.mock('../../../navigation/selectors') vi.mock('../../../file-data/selectors') vi.mock('../../Hints/useBlockingHint') -vi.mock('../utils') - +vi.mock('../utils/getUnusedStagingAreas') +vi.mock('../utils/getUnusedTrash') const render = () => { return renderWithProviders(, { i18nInstance: i18n })[0] } describe('FileSidebar', () => { beforeEach(() => { - vi.mocked(getUnusedEntities).mockReturnValue([]) vi.mocked(getUnusedStagingAreas).mockReturnValue([]) vi.mocked(getUnusedTrash).mockReturnValue({ trashBinUnused: false, @@ -91,19 +91,54 @@ describe('FileSidebar', () => { fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Your protocol has no steps') }) - it('renders the unused pipette and module warning', () => { - vi.mocked(getUnusedEntities).mockReturnValue([ - { - mount: 'left', - name: 'p1000_96', - id: 'pipetteId', - tiprackDefURI: 'mockURI', - spec: { - name: 'mock pip name', - displayName: 'mock display name', + it('renders the unused pipette warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: {}, + pipettes: { + pipetteId: { + mount: 'left', + name: 'p1000_96', + id: 'pipetteId', + tiprackLabwareDef: [fixtureTiprack300ul as LabwareDefinition2], + tiprackDefURI: ['mockDefUri'], + spec: { + displayName: 'mock display name', + } as any, + }, + }, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused pipette') + }) + it('renders the unused pieptte and module warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: { + pipetteId: { + mount: 'left', + name: 'p1000_96', + id: 'pipetteId', + tiprackLabwareDef: [fixtureTiprack300ul as LabwareDefinition2], + tiprackDefURI: ['mockDefUri'], + spec: { + displayName: 'mock display name', + } as any, }, }, - ]) + additionalEquipmentOnDeck: {}, + labware: {}, + }) render() fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Unused pipette and module') @@ -140,4 +175,55 @@ describe('FileSidebar', () => { fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Unused gripper') }) + it('renders the unused module warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: {}, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused module') + screen.getByText( + 'The Temperature module specified in your protocol in Slot A1 is not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.' + ) + }) + it('renders the unused modules warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + moduleId2: { + slot: 'B1', + moduleState: {} as any, + id: 'moduleId2', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: {}, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused modules') + screen.getByText( + 'One or more modules specified in your protocol in Slot(s) A1,B1 are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.' + ) + }) }) diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index 272e51a9363..4548d19e57c 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -49,6 +49,10 @@ "title": "Missing labware", "body": "Your module has no labware on it. We recommend you add labware before proceeding." }, + "multiple_modules_without_labware": { + "title": "Missing labware", + "body": "One or more module has no labware on it. We recommend you add labware before proceeding" + }, "export_v8_protocol_7_1": { "title": "Robot and app update may be required", "body1": "This protocol can only run on app and robot server version", @@ -256,12 +260,12 @@ }, "unused_module": { "heading": "Unused module", - "body1": "The {{modulesDetails}} specified in your protocol are not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.", + "body1": "The {{modulesDetails}} specified in your protocol in Slot {{slotName}} is not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.", "body2": "If you don't intend to use the module, please consider removing it from your protocol." }, "unused_modules": { "heading": "Unused modules", - "body1": "The {{modulesDetails}} specified in your protocol are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.", + "body1": "One or more modules specified in your protocol in Slot(s) {{slotName}} are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.", "body2": "If you don't intend to use these modules, please consider removing them from your protocol." }, "unused_gripper": { From 75acb0559d029ac8c8835cb3cecf7e45b9b80dc6 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Mon, 8 Apr 2024 18:50:29 -0400 Subject: [PATCH 14/49] feat(robot server): add a POST method on the analyses endpoint (#14828) Closes AUTH-255 # Overview Adds a POST method to the existing `/protocols/{protocolId}/analyses` endpoint in order to post a new analysis for an existing protocol. This endpoint will take a request body with two optional fields: - `runTimeParameterValues` - `forceReAnalyze` The new method can affect the analyses in three ways: 1. When the request is sent with `forceReAnalyze=True`, the server will unconditionally start a new analysis for the protocol using any RTP data sent along with it. It will return a 201 CREATED status and respond with a list of analysis summaries of all the analyses (ordered oldest first), including the newly started analysis. 2. When the request is sent without the `forceReAnalyze` field (or with `forceReAnalyze=False`), then the server will check the last analysis of the protocol - if the RTP values used for it were **different** from the RTP values sent with the current request, then the server will start a new analysis using the new RTP values. It will return a 201 CREATED status and respond with a list of analysis summaries of all the analyses, including the newly started analysis. - if the RTP values used for it were the **same** as the RTP values sent with the current request, then the server will **NOT** start a new analysis. It will return a 200 OK status, and simply return the existing list of analysis summaries. This request requires the last analysis of the protocol to have been completed before handling this request. If the last analysis is pending, it will return a 503 error. # Test Plan Test out the above three cases and anything else you can think might affect the behavior. It's pretty well tested in unit & integration tests and it is also the same logic used for handling analyses from `POST /protocols`, so it is expected to work well when used as tested in integration tests. # Review requests Usual review for code sanity check. # Risk assessment Low. New HTTP API not yet used anywhere. --- .../robot_server/protocols/analysis_models.py | 14 +- robot-server/robot_server/protocols/router.py | 166 ++++++++++++++---- .../protocols/test_analyses.tavern.yaml | 19 ++ ...lyses_with_run_time_parameters.tavern.yaml | 23 ++- .../tests/protocols/test_protocols_router.py | 132 +++++++++++++- 5 files changed, 320 insertions(+), 34 deletions(-) diff --git a/robot-server/robot_server/protocols/analysis_models.py b/robot-server/robot_server/protocols/analysis_models.py index c5827e577da..c8b11f2db25 100644 --- a/robot-server/robot_server/protocols/analysis_models.py +++ b/robot-server/robot_server/protocols/analysis_models.py @@ -2,7 +2,7 @@ # TODO(mc, 2021-08-25): add modules to simulation result from enum import Enum -from opentrons.protocol_engine.types import RunTimeParameter +from opentrons.protocol_engine.types import RunTimeParameter, RunTimeParamValuesType from opentrons_shared_data.robot.dev_types import RobotType from pydantic import BaseModel, Field from typing import List, Optional, Union, NamedTuple @@ -40,6 +40,18 @@ class AnalysisResult(str, Enum): NOT_OK = "not-ok" +class AnalysisRequest(BaseModel): + """Model for analysis request body.""" + + runTimeParameterValues: RunTimeParamValuesType = Field( + default={}, + description="Key-value pairs of run-time parameters defined in a protocol.", + ) + forceReAnalyze: bool = Field( + False, description="Whether to force start a new analysis." + ) + + class AnalysisSummary(BaseModel): """Base model for an analysis of a protocol.""" diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 8ae9365de36..d3375f535d4 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -4,8 +4,9 @@ from textwrap import dedent from datetime import datetime from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional, Union, Tuple +from opentrons.protocol_engine.types import RunTimeParamValuesType from opentrons_shared_data.robot import user_facing_robot_type from typing_extensions import Literal @@ -32,13 +33,14 @@ SimpleEmptyBody, MultiBodyMeta, PydanticResponse, + RequestModel, ) from .protocol_auto_deleter import ProtocolAutoDeleter from .protocol_models import Protocol, ProtocolFile, Metadata from .protocol_analyzer import ProtocolAnalyzer from .analysis_store import AnalysisStore, AnalysisNotFoundError, AnalysisIsPendingError -from .analysis_models import ProtocolAnalysis +from .analysis_models import ProtocolAnalysis, AnalysisRequest, AnalysisSummary from .protocol_store import ( ProtocolStore, ProtocolResource, @@ -162,7 +164,7 @@ class ProtocolLinks(BaseModel): status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, ) -async def create_protocol( # noqa: C901 +async def create_protocol( files: List[UploadFile] = File(...), # use Form because request is multipart/form-data # https://fastapi.tiangolo.com/tutorial/request-forms-and-files/ @@ -238,35 +240,18 @@ async def create_protocol( # noqa: C901 if cached_protocol_id is not None: resource = protocol_store.get(protocol_id=cached_protocol_id) - analyses = analysis_store.get_summaries_by_protocol( - protocol_id=cached_protocol_id - ) try: - if ( - # Unexpected situations, like powering off the robot after a protocol upload - # but before the analysis is complete, can leave the protocol resource - # without an associated analysis. - len(analyses) == 0 - or - # The most recent analysis was done using different RTP values - not await analysis_store.matching_rtp_values_in_analysis( - analysis_summary=analyses[-1], new_rtp_values=parsed_rtp - ) - ): - # This protocol exists in database but needs to be (re)analyzed - task_runner.run( - protocol_analyzer.analyze, - protocol_resource=resource, - analysis_id=analysis_id, - run_time_param_values=parsed_rtp, - ) - analyses.append( - analysis_store.add_pending( - protocol_id=cached_protocol_id, - analysis_id=analysis_id, - ) - ) + analysis_summaries, _ = await _start_new_analysis_if_necessary( + protocol_id=cached_protocol_id, + analysis_id=analysis_id, + rtp_values=parsed_rtp, + force_reanalyze=False, + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + ) except AnalysisIsPendingError as error: raise LastAnalysisPending(detail=str(error)).as_error( status.HTTP_503_SERVICE_UNAVAILABLE @@ -278,7 +263,7 @@ async def create_protocol( # noqa: C901 protocolType=resource.source.config.protocol_type, robotType=resource.source.robot_type, metadata=Metadata.parse_obj(resource.source.metadata), - analysisSummaries=analyses, + analysisSummaries=analysis_summaries, key=resource.protocol_key, files=[ ProtocolFile(name=f.path.name, role=f.role) @@ -357,6 +342,53 @@ async def create_protocol( # noqa: C901 ) +async def _start_new_analysis_if_necessary( + protocol_id: str, + analysis_id: str, + force_reanalyze: bool, + rtp_values: RunTimeParamValuesType, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> Tuple[List[AnalysisSummary], bool]: + """Check RTP values and start a new analysis if necessary. + + Returns a tuple of the latest list of analysis summaries (including any newly + started analysis) and whether a new analysis was started. + """ + resource = protocol_store.get(protocol_id=protocol_id) + analyses = analysis_store.get_summaries_by_protocol(protocol_id=protocol_id) + started_new_analysis = False + if ( + force_reanalyze + or + # Unexpected situations, like powering off the robot after a protocol upload + # but before the analysis is complete, can leave the protocol resource + # without an associated analysis. + len(analyses) == 0 + or + # The most recent analysis was done using different RTP values + not await analysis_store.matching_rtp_values_in_analysis( + analysis_summary=analyses[-1], new_rtp_values=rtp_values + ) + ): + task_runner.run( + protocol_analyzer.analyze, + protocol_resource=resource, + analysis_id=analysis_id, + run_time_param_values=rtp_values, + ) + started_new_analysis = True + analyses.append( + analysis_store.add_pending( + protocol_id=protocol_id, + analysis_id=analysis_id, + ) + ) + return analyses, started_new_analysis + + @PydanticResponse.wrap_route( protocols_router.get, path="/protocols", @@ -519,6 +551,78 @@ async def delete_protocol_by_id( ) +@PydanticResponse.wrap_route( + protocols_router.post, + path="/protocols/{protocolId}/analyses", + summary="Analyze the protocol", + description=dedent( + """ + Generate an analysis for the protocol, based on last analysis and current request data. + """ + ), + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_200_OK: {"model": SimpleMultiBody[AnalysisSummary]}, + status.HTTP_201_CREATED: {"model": SimpleMultiBody[AnalysisSummary]}, + status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, + }, +) +async def create_protocol_analysis( + protocolId: str, + request_body: Optional[RequestModel[AnalysisRequest]] = None, + protocol_store: ProtocolStore = Depends(get_protocol_store), + analysis_store: AnalysisStore = Depends(get_analysis_store), + protocol_analyzer: ProtocolAnalyzer = Depends(get_protocol_analyzer), + task_runner: TaskRunner = Depends(get_task_runner), + analysis_id: str = Depends(get_unique_id, use_cache=False), +) -> PydanticResponse[SimpleMultiBody[AnalysisSummary]]: + """Start a new analysis for the given existing protocol. + + Starts a new analysis for the protocol along with the provided run-time parameter + values (if any), and appends it to the existing analyses. + + If the last analysis in the existing analyses used the same RTP values, then a new + analysis is not created. + + If `forceAnalyze` is True, this will always start a new analysis. + + Returns: List of analysis summaries available for the protocol, ordered as + most recently started analysis last. + """ + if not protocol_store.has(protocolId): + raise ProtocolNotFound(detail=f"Protocol {protocolId} not found").as_error( + status.HTTP_404_NOT_FOUND + ) + try: + ( + analysis_summaries, + started_new_analysis, + ) = await _start_new_analysis_if_necessary( + protocol_id=protocolId, + analysis_id=analysis_id, + rtp_values=request_body.data.runTimeParameterValues if request_body else {}, + force_reanalyze=request_body.data.forceReAnalyze if request_body else False, + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + ) + except AnalysisIsPendingError as error: + raise LastAnalysisPending(detail=str(error)).as_error( + status.HTTP_503_SERVICE_UNAVAILABLE + ) from error + return await PydanticResponse.create( + content=SimpleMultiBody.construct( + data=analysis_summaries, + meta=MultiBodyMeta(cursor=0, totalLength=len(analysis_summaries)), + ), + status_code=status.HTTP_201_CREATED + if started_new_analysis + else status.HTTP_200_OK, + ) + + @PydanticResponse.wrap_route( protocols_router.get, path="/protocols/{protocolId}/analyses", diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml index a756ea10e1b..0451b3eebc4 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml @@ -84,3 +84,22 @@ stages: # We need to make sure we get the Content-Type right because FastAPI won't do it for us. Content-Type: application/json json: !force_format_include '{analysis_data}' + + + - name: Check that a new analysis is started with forceReAnalyze + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + forceReAnalyze: true + response: + strict: + - json:off + status_code: 201 + json: + data: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: pending diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml index 3ad017a546d..fa37eadc20c 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml @@ -177,4 +177,25 @@ stages: description: What pipette to use during the protocol. commands: # Check for this command's presence as a smoke test that the analysis isn't empty. - - commandType: loadPipette \ No newline at end of file + - commandType: loadPipette + + - name: Check that a new analysis is started for the protocol because of new RTP values + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + runTimeParameterValues: + sample_count: 2 + response: + strict: + - json:off + status_code: 201 + json: + data: + - id: '{analysis_id}' + status: completed + - id: '{analysis_id2}' + status: completed + - id: !anystr + status: pending \ No newline at end of file diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index ffb02d929b1..88605f81a3b 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -7,6 +7,7 @@ from fastapi import UploadFile from pathlib import Path +from opentrons.protocol_engine.types import RunTimeParamValuesType from opentrons.protocols.api_support.types import APIVersion from opentrons.protocol_reader import ( @@ -23,7 +24,7 @@ ) from robot_server.errors.error_responses import ApiError -from robot_server.service.json_api import SimpleEmptyBody, MultiBodyMeta +from robot_server.service.json_api import SimpleEmptyBody, MultiBodyMeta, RequestModel from robot_server.service.task_runner import TaskRunner from robot_server.protocols.analysis_store import ( AnalysisStore, @@ -38,6 +39,7 @@ CompletedAnalysis, PendingAnalysis, AnalysisResult, + AnalysisRequest, ) from robot_server.protocols.protocol_models import ( @@ -56,6 +58,7 @@ from robot_server.protocols.router import ( ProtocolLinks, create_protocol, + create_protocol_analysis, get_protocols, get_protocol_ids, get_protocol_by_id, @@ -1393,3 +1396,130 @@ async def test_get_protocol_analysis_as_document_analysis_not_found( assert exc_info.value.status_code == 404 assert exc_info.value.content["errors"][0]["id"] == "AnalysisNotFound" + + +async def test_create_protocol_analyses_with_same_rtp_values( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should not start a new analysis for the new rtp values.""" + rtp_values: RunTimeParamValuesType = {"vol": 123, "dry_run": True, "mount": "left"} + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], rtp_values + ) + ).then_return(True) + + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel( + data=AnalysisRequest(runTimeParameterValues=rtp_values) + ), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == analysis_summaries + assert result.status_code == 200 + + +async def test_update_protocol_analyses_with_new_rtp_values( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should start a new analysis for the new rtp values.""" + rtp_values: RunTimeParamValuesType = {"vol": 123, "dry_run": True, "mount": "left"} + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], rtp_values + ) + ).then_return(False) + decoy.when(analysis_store.add_pending("protocol-id", "analysis-id-2")).then_return( + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING) + ) + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel( + data=AnalysisRequest(runTimeParameterValues=rtp_values) + ), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == [ + AnalysisSummary(id="analysis-id", status=AnalysisStatus.COMPLETED), + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING), + ] + assert result.status_code == 201 + + +async def test_update_protocol_analyses_with_forced_reanalysis( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should start a new analysis for the protocol, regardless of rtp values.""" + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summary=analysis_summaries[-1], new_rtp_values={} + ) + ).then_return(True) + decoy.when(analysis_store.add_pending("protocol-id", "analysis-id-2")).then_return( + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING) + ) + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel(data=AnalysisRequest(forceReAnalyze=True)), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == [ + AnalysisSummary(id="analysis-id", status=AnalysisStatus.COMPLETED), + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING), + ] + assert result.status_code == 201 From 3643bc7f669d05b179edad1676f9788c9c2001c7 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 9 Apr 2024 09:19:40 -0400 Subject: [PATCH 15/49] feat(protocol-designer): temperature form multiple module support (#14835) closes AUTH-2 --- .../LabwareOverlays/LabwareHighlight.tsx | 15 +- .../src/components/Hints/index.tsx | 2 + .../StepEditForm/forms/TemperatureForm.tsx | 100 +++-- .../forms/__tests__/TemperatureForm.test.tsx | 95 +++++ .../components/steplist/ModuleStepItems.tsx | 57 ++- .../src/components/steplist/StepItem.tsx | 8 +- .../src/containers/ConnectedStepItem.tsx | 18 +- .../__tests__/ConnectedStepItem.test.tsx | 378 +++++++++++++++++- .../src/localization/en/application.json | 1 + .../src/steplist/generateSubstepItem.ts | 1 + .../steplist/test/generateSubsteps.test.ts | 3 + protocol-designer/src/steplist/types.ts | 7 +- protocol-designer/src/tutorial/index.ts | 1 + protocol-designer/src/ui/modules/selectors.ts | 37 +- protocol-designer/src/ui/modules/utils.ts | 85 +++- .../addAndSelectStepWithHints.test.ts | 81 +++- .../src/ui/steps/actions/thunks/index.ts | 15 +- protocol-designer/src/ui/steps/selectors.ts | 12 +- 18 files changed, 768 insertions(+), 148 deletions(-) create mode 100644 protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/LabwareHighlight.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/LabwareHighlight.tsx index e0a8500c4c8..320d1074977 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/LabwareHighlight.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/LabwareHighlight.tsx @@ -3,11 +3,14 @@ import cx from 'classnames' import { useSelector } from 'react-redux' import { Icon } from '@opentrons/components' import { getHoveredStepLabware, getHoveredStepId } from '../../../ui/steps' -import { getSavedStepForms } from '../../../step-forms/selectors' +import { + getLabwareEntities, + getSavedStepForms, +} from '../../../step-forms/selectors' import { THERMOCYCLER_PROFILE } from '../../../constants' import styles from './LabwareOverlays.module.css' -import { LabwareOnDeck } from '../../../step-forms' +import type { LabwareOnDeck } from '../../../step-forms' interface LabwareHighlightProps { labwareOnDeck: LabwareOnDeck @@ -17,8 +20,14 @@ export const LabwareHighlight = ( props: LabwareHighlightProps ): JSX.Element | null => { const { labwareOnDeck } = props + const labwareEntities = useSelector(getLabwareEntities) + const adapterId = + labwareEntities[labwareOnDeck.slot] != null + ? labwareEntities[labwareOnDeck.slot].id + : null + const highlighted = useSelector(getHoveredStepLabware).includes( - labwareOnDeck.id + adapterId ?? labwareOnDeck.id ) let isTcProfile = false diff --git a/protocol-designer/src/components/Hints/index.tsx b/protocol-designer/src/components/Hints/index.tsx index af77a54193b..6f5bafd2527 100644 --- a/protocol-designer/src/components/Hints/index.tsx +++ b/protocol-designer/src/components/Hints/index.tsx @@ -74,12 +74,14 @@ export const Hints = (): JSX.Element | null => {

{t(`hint.${hintKey}.body3`)}

) + case 'multiple_modules_without_labware': case 'module_without_labware': return ( <>

{t(`alert:hint.${hintKey}.body`)}

) + case 'thermocycler_lid_passive_cooling': return ( <> diff --git a/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx b/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx index c14b358dc0c..bcd35a1636f 100644 --- a/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx @@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next' import { FormGroup } from '@opentrons/components' import { selectors as uiModuleSelectors } from '../../../ui/modules' import { StepFormDropdown, RadioGroupField, TextField } from '../fields' -import styles from '../StepEditForm.module.css' import type { StepFormProps } from '../types' -export const TemperatureForm = (props: StepFormProps): JSX.Element => { +import styles from '../StepEditForm.module.css' + +export function TemperatureForm(props: StepFormProps): JSX.Element { const { t } = useTranslation(['application', 'form']) const moduleLabwareOptions = useSelector( uiModuleSelectors.getTemperatureLabwareOptions ) - const temperatureModuleId = useSelector( - uiModuleSelectors.getSingleTemperatureModuleId + const temperatureModuleIds = useSelector( + uiModuleSelectors.getTemperatureModuleIds ) const { propsForFields } = props @@ -36,56 +37,47 @@ export const TemperatureForm = (props: StepFormProps): JSX.Element => { options={moduleLabwareOptions} /> - {/* TODO (ka 2020-1-6): - moduleID dropdown will autoselect when creating a new step, - but this will not be the case when returning to a never saved form. - Rather than defaulting to one or the other when null, - display a message (copy, design, etc TBD) that you need to select a module to continue - */} - - {moduleId === null && ( -

- Please ensure a compatible module is present on the deck and - selected to create a temperature step. -

- )} - {moduleId === temperatureModuleId && temperatureModuleId != null && ( - <> -
- - {setTemperature === 'true' && ( - - )} -
-
- -
- - )} + {temperatureModuleIds != null + ? temperatureModuleIds.map(id => + id === moduleId ? ( + +
+ + {setTemperature === 'true' && ( + + )} +
+
+ +
+
+ ) : null + ) + : null} ) diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx new file mode 100644 index 00000000000..a32894d3b84 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { + getTemperatureLabwareOptions, + getTemperatureModuleIds, +} from '../../../../ui/modules/selectors' +import { TemperatureForm } from '../TemperatureForm' + +vi.mock('../../../../ui/modules/selectors', async importOriginal => { + const actualFields = await importOriginal< + typeof import('../../../../ui/modules/selectors') + >() + return { + ...actualFields, + getTemperatureLabwareOptions: vi.fn(), + getTemperatureModuleIds: vi.fn(), + } +}) +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('TemperatureForm', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + formData: { + id: 'formId', + stepType: 'temperature', + moduleId: 'mockId', + setTemperature: true, + } as any, + focusHandlers: { + blur: vi.fn(), + focus: vi.fn(), + dirtyFields: [], + focusedField: null, + }, + propsForFields: { + moduleId: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'setTemperature', + updateValue: vi.fn(), + value: 'mockId', + }, + setTemperature: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'setTemperature', + updateValue: vi.fn(), + value: true, + }, + targetTemperature: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'targetTemperature', + updateValue: vi.fn(), + value: null, + }, + }, + } + + vi.mocked(getTemperatureModuleIds).mockReturnValue(['mockId']) + vi.mocked(getTemperatureLabwareOptions).mockReturnValue([ + { + name: 'mock module', + value: 'mockId', + }, + ]) + }) + + it('renders a temperature module', () => { + render(props) + screen.getByText('temperature') + screen.getByText('module') + const change = screen.getByText('Change to temperature') + screen.getByText('Deactivate module') + fireEvent.click(change) + const changeTempInput = screen.getByRole('combobox', { name: '' }) + fireEvent.change(changeTempInput, { target: { value: 40 } }) + }) +}) diff --git a/protocol-designer/src/components/steplist/ModuleStepItems.tsx b/protocol-designer/src/components/steplist/ModuleStepItems.tsx index f3e91c1b73d..548caf2964d 100644 --- a/protocol-designer/src/components/steplist/ModuleStepItems.tsx +++ b/protocol-designer/src/components/steplist/ModuleStepItems.tsx @@ -9,8 +9,9 @@ import { } from '@opentrons/components' import { PDListItem } from '../lists' import { LabwareTooltipContents } from './LabwareTooltipContents' +import type { ModuleType } from '@opentrons/shared-data' + import styles from './StepItem.module.css' -import { ModuleType } from '@opentrons/shared-data' export interface ModuleStepItemRowProps { label?: string | null @@ -31,44 +32,64 @@ export const ModuleStepItemRow = ( ) -interface Props { - action?: string +interface ModuleStepItemsProps { moduleType: ModuleType actionText: string - labwareNickname?: string | null - message?: string | null + moduleSlot?: string + action?: string children?: React.ReactNode hideHeader?: boolean + labwareNickname?: string | null + message?: string | null } -export const ModuleStepItems = (props: Props): JSX.Element => { - const { t } = useTranslation('modules') +export function ModuleStepItems(props: ModuleStepItemsProps): JSX.Element { + const { + moduleType, + actionText, + moduleSlot, + action, + hideHeader, + labwareNickname, + children, + message, + } = props + const { t } = useTranslation(['modules', 'application']) const [targetProps, tooltipProps] = useHoverTooltip({ placement: 'bottom-start', strategy: TOOLTIP_FIXED, }) + const moduleLongName = t(`module_long_names.${moduleType}`) + return ( <> - {!props.hideHeader && ( + {!Boolean(hideHeader) ? (
  • - {t(`module_long_names.${props.moduleType}`)} - {props.action} + + {moduleSlot != null + ? t('application:module_and_slot', { + moduleLongName, + slotName: moduleSlot, + }) + : moduleLongName} + + {action}
  • - )} + ) : null} - + - {props.children} - {props.message && ( + {children} + {message != null ? ( - "{props.message}" + "{message}" - )} + ) : null} ) } diff --git a/protocol-designer/src/components/steplist/StepItem.tsx b/protocol-designer/src/components/steplist/StepItem.tsx index c51502348a2..0fbb338cc0f 100644 --- a/protocol-designer/src/components/steplist/StepItem.tsx +++ b/protocol-designer/src/components/steplist/StepItem.tsx @@ -25,6 +25,7 @@ import { makeTemperatureText, makeTimerText, } from '../../utils' +import { InitialDeckSetup } from '../../step-forms' import { PDListItem, TitledStepList } from '../lists' import { TitledListNotes } from '../TitledListNotes' import { AspirateDispenseHeader } from './AspirateDispenseHeader' @@ -121,11 +122,10 @@ export interface StepItemContentsProps { rawForm: FormData | null | undefined stepType: StepType substeps: SubstepItemData | null | undefined - ingredNames: WellIngredientNames labwareNicknamesById: { [labwareId: string]: string } additionalEquipmentEntities: AdditionalEquipmentEntities - + modules: InitialDeckSetup['modules'] highlightSubstep: (substepIdentifier: SubstepIdentifier) => unknown hoveredSubstep: SubstepIdentifier | null | undefined } @@ -293,6 +293,7 @@ export const StepItemContents = ( props: StepItemContentsProps ): JSX.Element | JSX.Element[] | null => { const { + modules, rawForm, stepType, substeps, @@ -326,6 +327,8 @@ export const StepItemContents = ( if (substeps && substeps.substepType === 'temperature') { const temperature = makeTemperatureText(substeps.temperature, t) + const moduleSlot = + substeps.moduleId != null ? modules[substeps.moduleId].slot : '' return ( ) } diff --git a/protocol-designer/src/containers/ConnectedStepItem.tsx b/protocol-designer/src/containers/ConnectedStepItem.tsx index a6b4ceb1f26..a3ebcb05f41 100644 --- a/protocol-designer/src/containers/ConnectedStepItem.tsx +++ b/protocol-designer/src/containers/ConnectedStepItem.tsx @@ -24,7 +24,6 @@ import { SelectMultipleStepsAction, } from '../ui/steps' import { selectors as fileDataSelectors } from '../file-data' - import { StepItem, StepItemContents, @@ -38,12 +37,15 @@ import { ConfirmDeleteModal, DeleteModalType, } from '../components/modals/ConfirmDeleteModal' +import { + getAdditionalEquipmentEntities, + getInitialDeckSetup, +} from '../step-forms/selectors' -import { SubstepIdentifier } from '../steplist/types' -import { StepIdType } from '../form-types' -import { BaseState, ThunkAction } from '../types' -import { getAdditionalEquipmentEntities } from '../step-forms/selectors' -import { ThunkDispatch } from 'redux-thunk' +import type { ThunkDispatch } from 'redux-thunk' +import type { SubstepIdentifier } from '../steplist/types' +import type { StepIdType } from '../form-types' +import type { BaseState, ThunkAction } from '../types' export interface ConnectedStepItemProps { stepId: StepIdType @@ -86,7 +88,7 @@ export const ConnectedStepItem = ( const hasWarnings = hasTimelineWarningsPerStep[stepId] || hasFormLevelWarningsPerStep[stepId] - + const initialDeckSetup = useSelector(getInitialDeckSetup) const collapsed = useSelector(getCollapsedSteps)[stepId] const hoveredSubstep = useSelector(getHoveredSubstep) const hoveredStep = useSelector(getHoveredStepId) @@ -217,6 +219,7 @@ export const ConnectedStepItem = ( } const stepItemContentsProps: StepItemContentsProps = { + modules: initialDeckSetup.modules, rawForm: step, stepType: step.stepType, substeps, @@ -236,7 +239,6 @@ export const ConnectedStepItem = ( return CLOSE_STEP_FORM_WITH_CHANGES } } - return ( <> {showConfirmation && ( diff --git a/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx b/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx index 4d03b5c16ac..cce62e03887 100644 --- a/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx +++ b/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx @@ -1,5 +1,379 @@ -import { describe, it } from 'vitest' +import * as React from 'react' +import { describe, it, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { fixture96Plate } from '@opentrons/shared-data' +import { renderWithProviders } from '../../__testing-utils__' +import { i18n } from '../../localization' +import { + getAdditionalEquipmentEntities, + getArgsAndErrorsByStepId, + getBatchEditFormHasUnsavedChanges, + getCurrentFormCanBeSaved, + getCurrentFormHasUnsavedChanges, + getInitialDeckSetup, + getOrderedStepIds, + getSavedStepForms, +} from '../../step-forms/selectors' +import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' +import { getErrorStepId, getSubsteps } from '../../file-data/selectors' +import { getHasTimelineWarningsPerStep } from '../../top-selectors/timelineWarnings' +import { getHasFormLevelWarningsPerStep } from '../../dismiss/selectors' +import { + getCollapsedSteps, + getHoveredSubstep, + getIsMultiSelectMode, + getMultiSelectItemIds, + getMultiSelectLastSelected, + getSelectedStepId, +} from '../../ui/steps' +import { getLabwareNicknamesById } from '../../ui/labware/selectors' +import { ConnectedStepItem } from '../ConnectedStepItem' +import type { LabwareDefinition2 } from '@opentrons/shared-data' +vi.mock('../../step-forms/selectors') +vi.mock('../../file-data/selectors') +vi.mock('../../top-selectors/timelineWarnings') +vi.mock('../../dismiss/selectors') +vi.mock('../../ui/steps') +vi.mock('../../labware-ingred/selectors') +vi.mock('../../ui/labware/selectors') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} +const pauseStepId = 'pauseId' +const magnetStepId = 'magnetStepId' +const heaterShakerStepId = 'hsStepId' +const thermocyclerStepId = 'tcStepId' +const temperatureStepId = 'tempStepId' +const moveLabwareStepId = 'moveLabwareId' + +// TODO(jr, 4/8/24): add test coverage for mix and moveLiquid!!! describe('ConnectedStepItem', () => { - it.todo('replace deprecated enzyme test') + let props: React.ComponentProps + beforeEach(() => { + props = { + stepId: pauseStepId, + stepNumber: 2, + onStepContextMenu: vi.fn(), + } + vi.mocked(getSavedStepForms).mockReturnValue({ + [pauseStepId]: { + stepType: 'pause', + id: pauseStepId, + pauseHour: '1', + pauseMinute: '10', + pauseSecond: '5', + pauseMessage: 'mock message', + pauseTemperature: '10', + }, + [magnetStepId]: { + stepType: 'magnet', + id: magnetStepId, + }, + [heaterShakerStepId]: { + stepType: 'heaterShaker', + id: heaterShakerStepId, + }, + [thermocyclerStepId]: { + stepType: 'thermocycler', + id: thermocyclerStepId, + }, + [temperatureStepId]: { + stepType: 'temperature', + id: temperatureStepId, + }, + [moveLabwareStepId]: { + stepType: 'moveLabware', + id: moveLabwareStepId, + }, + }) + vi.mocked(getArgsAndErrorsByStepId).mockReturnValue({ + [pauseStepId]: { + errors: false, + stepArgs: null, + }, + [magnetStepId]: { + errors: false, + stepArgs: null, + }, + [heaterShakerStepId]: { + errors: false, + stepArgs: null, + }, + [thermocyclerStepId]: { + errors: false, + stepArgs: null, + }, + [temperatureStepId]: { + errors: false, + stepArgs: null, + }, + [moveLabwareStepId]: { + errors: false, + stepArgs: null, + }, + }) + vi.mocked(getErrorStepId).mockReturnValue(null) + vi.mocked(getHasTimelineWarningsPerStep).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: false, + [moveLabwareStepId]: false, + }) + vi.mocked(getHasFormLevelWarningsPerStep).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: false, + [moveLabwareStepId]: false, + }) + vi.mocked(getInitialDeckSetup).mockReturnValue({ + pipettes: {}, + modules: { + thermocyclerId: { + id: 'thermocyclerId', + type: 'thermocyclerModuleType', + model: 'thermocyclerModuleV2', + slot: 'B1', + moduleState: {} as any, + }, + temperatureId: { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, + }, + heaterShakerId: { + id: 'heaterShakerId', + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + slot: 'D1', + moduleState: {} as any, + }, + magnetId: { + id: 'magnetId', + type: 'magneticModuleType', + model: 'magneticModuleV2', + slot: 'C1', + moduleState: {} as any, + }, + }, + additionalEquipmentOnDeck: { + stagingAreaId: { + name: 'stagingArea', + location: 'B3', + id: 'stagingAreaId', + }, + }, + labware: { + labwareId: { + id: 'labwareId', + labwareDefURI: `opentrons/fixture_96_plate/1`, + slot: 'A2', + def: fixture96Plate as LabwareDefinition2, + }, + }, + }) + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + }) + vi.mocked(getHoveredSubstep).mockReturnValue(null) + vi.mocked(getSelectedStepId).mockReturnValue(pauseStepId) + vi.mocked(getOrderedStepIds).mockReturnValue([ + pauseStepId, + magnetStepId, + heaterShakerStepId, + thermocyclerStepId, + moveLabwareStepId, + temperatureStepId, + ]) + vi.mocked(getMultiSelectItemIds).mockReturnValue(null) + vi.mocked(getMultiSelectLastSelected).mockReturnValue(null) + vi.mocked(getIsMultiSelectMode).mockReturnValue(false) + vi.mocked(getSubsteps).mockReturnValue({ + [pauseStepId]: { + substepType: 'pause', + pauseStepArgs: { + commandCreatorFnName: 'delay', + wait: 10, + name: 'pause', + description: '', + meta: { hours: 1, minutes: 10, seconds: 15 }, + }, + }, + [magnetStepId]: { + substepType: 'magnet', + engage: true, + labwareNickname: 'mockLabware', + message: 'engaging height', + }, + [heaterShakerStepId]: { + substepType: 'heaterShaker', + labwareNickname: 'mockLabware', + targetHeaterShakerTemperature: 20, + targetSpeed: 200, + latchOpen: false, + heaterShakerTimerMinutes: 5, + heaterShakerTimerSeconds: 11, + }, + [thermocyclerStepId]: { + substepType: 'thermocyclerProfile', + blockTargetTempHold: 30, + labwareNickname: 'mockLabware', + lidOpenHold: false, + lidTargetTempHold: 32, + meta: { rawProfileItems: [] }, + profileSteps: [ + { holdTime: 7, temperature: 87 }, + { holdTime: 2, temperature: 55 }, + ], + profileTargetLidTemp: 40, + profileVolume: 21, + }, + [temperatureStepId]: { + substepType: 'temperature', + temperature: 18, + labwareNickname: 'mockLabware', + moduleId: 'temperatureId', + message: 'mock message', + }, + [moveLabwareStepId]: { + substepType: 'moveLabware', + moveLabwareArgs: { + commandCreatorFnName: 'moveLabware', + name: 'move labware', + description: '', + labware: 'labwareId', + useGripper: false, + newLocation: { slotName: 'B2' }, + }, + }, + }) + vi.mocked(labwareIngredSelectors.getLiquidNamesById).mockReturnValue({}) + vi.mocked(getLabwareNicknamesById).mockReturnValue({}) + vi.mocked(getAdditionalEquipmentEntities).mockReturnValue({ + stagingAreaId: { name: 'stagingArea', location: 'B3', id: 'stagingArea' }, + }) + vi.mocked(getCurrentFormCanBeSaved).mockReturnValue(true) + vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false) + vi.mocked(getBatchEditFormHasUnsavedChanges).mockReturnValue(false) + }) + it('renders an expanded step item for pause', () => { + render(props) + screen.getByText('2. pause') + screen.getByText('Pause for Time') + screen.getByText('1 h') + screen.getByText('10 m') + screen.getByText('15 s') + }) + it('renders an expanded step item for magnet', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(magnetStepId) + props.stepId = magnetStepId + render(props) + screen.getByText('2. magnet') + screen.getByText('Magnetic module') + screen.getByText('mockLabware') + screen.getByText('engage') + }) + it('renders an expanded step item for heater-shaker', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: false, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(heaterShakerStepId) + props.stepId = heaterShakerStepId + render(props) + screen.getByText('2. heater-shaker') + screen.getByText('Heater-Shaker module') + screen.getByText('go to') + screen.getByText('mockLabware') + screen.getByText('20 °C') + screen.getByText('Labware Latch') + screen.getByText('Closed and Locked') + screen.getByText('Shaker') + screen.getByText('200 rpm') + screen.getByText('Deactivate after') + }) + it('renders an expanded step item for thermocycler', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(thermocyclerStepId) + props.stepId = thermocyclerStepId + render(props) + screen.getByText('2. thermocycler') + screen.getByText('Thermocycler module') + screen.getByText('profile') + screen.getByText('mockLabware') + screen.getByText('cycling') + screen.getByText('Lid (closed)') + screen.getByText('40 °C') + screen.getByText('Profile steps (0+ min)') + screen.getByText('Ending hold') + }) + it('renders an expanded step item for a temperature module', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: false, + [moveLabwareStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(temperatureStepId) + props.stepId = temperatureStepId + render(props) + screen.getByText('2. temperature') + screen.getByText('Temperature module in Slot C3') + screen.getByText('go to') + screen.getByText('mockLabware') + screen.getByText('18 °C') + screen.getByText('"mock message"') + }) + it('renders an expanded step for move labware', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: false, + }) + vi.mocked(getSelectedStepId).mockReturnValue(moveLabwareStepId) + props.stepId = moveLabwareStepId + render(props) + screen.getByText('2. move labware') + screen.getByText('Manually') + screen.getByText('labware') + screen.getByText('new location') + }) }) diff --git a/protocol-designer/src/localization/en/application.json b/protocol-designer/src/localization/en/application.json index dfa905ea70c..79625a33d51 100644 --- a/protocol-designer/src/localization/en/application.json +++ b/protocol-designer/src/localization/en/application.json @@ -23,6 +23,7 @@ "next": "Next", "no_batch_edit_shared_settings": "Batch editing of settings is only available for Transfer or Mix steps", "manually": "Manually", + "module_and_slot": "{{moduleLongName}} in Slot {{slotName}}", "stepType": { "mix": "mix", "moveLabware": "move labware", diff --git a/protocol-designer/src/steplist/generateSubstepItem.ts b/protocol-designer/src/steplist/generateSubstepItem.ts index f16b48f412c..edfac2fd19e 100644 --- a/protocol-designer/src/steplist/generateSubstepItem.ts +++ b/protocol-designer/src/steplist/generateSubstepItem.ts @@ -411,6 +411,7 @@ export function generateSubstepItem( temperature: temperature, labwareNickname: labwareNames?.nickname, message: stepArgs.message, + moduleId: stepArgs.module, } } diff --git a/protocol-designer/src/steplist/test/generateSubsteps.test.ts b/protocol-designer/src/steplist/test/generateSubsteps.test.ts index df8c3f5c334..1c2483e0487 100644 --- a/protocol-designer/src/steplist/test/generateSubsteps.test.ts +++ b/protocol-designer/src/steplist/test/generateSubsteps.test.ts @@ -622,6 +622,7 @@ describe('generateSubstepItem', () => { temperature: 45, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) @@ -652,6 +653,7 @@ describe('generateSubstepItem', () => { temperature: 0, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) @@ -680,6 +682,7 @@ describe('generateSubstepItem', () => { temperature: null, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) diff --git a/protocol-designer/src/steplist/types.ts b/protocol-designer/src/steplist/types.ts index 273fe87afdc..297c13e7194 100644 --- a/protocol-designer/src/steplist/types.ts +++ b/protocol-designer/src/steplist/types.ts @@ -5,9 +5,9 @@ import { PauseArgs, ThermocyclerProfileStepArgs, } from '@opentrons/step-generation' -import { ModuleType } from '@opentrons/shared-data' -import { StepIdType } from '../form-types' -import { FormError } from './formLevel/errors' +import type { ModuleType } from '@opentrons/shared-data' +import type { StepIdType } from '../form-types' +import type { FormError } from './formLevel/errors' // timeline start and end export const START_TERMINAL_ITEM_ID: '__initial_setup__' = '__initial_setup__' export const END_TERMINAL_ITEM_ID: '__end__' = '__end__' @@ -105,6 +105,7 @@ export interface TemperatureSubstepItem { substepType: 'temperature' temperature: number | null labwareNickname: string | null | undefined + moduleId: string | null message?: string } export interface PauseSubstepItem { diff --git a/protocol-designer/src/tutorial/index.ts b/protocol-designer/src/tutorial/index.ts index a0eee9ffff3..58a0f522c60 100644 --- a/protocol-designer/src/tutorial/index.ts +++ b/protocol-designer/src/tutorial/index.ts @@ -2,6 +2,7 @@ import * as actions from './actions' import { rootReducer, RootState } from './reducers' import * as selectors from './selectors' type HintKey = // normal hints + | 'multiple_modules_without_labware' | 'add_liquids_and_labware' | 'deck_setup_explanation' | 'module_without_labware' diff --git a/protocol-designer/src/ui/modules/selectors.ts b/protocol-designer/src/ui/modules/selectors.ts index 75057c88dfa..1d5ec7bdb08 100644 --- a/protocol-designer/src/ui/modules/selectors.ts +++ b/protocol-designer/src/ui/modules/selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from 'reselect' +import mapValues from 'lodash/mapValues' import { getLabwareDisplayName, MAGNETIC_MODULE_TYPE, @@ -6,7 +7,6 @@ import { THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, } from '@opentrons/shared-data' -import mapValues from 'lodash/mapValues' import { getInitialDeckSetup } from '../../step-forms/selectors' import { getLabwareNicknamesById } from '../labware/selectors' import { @@ -15,10 +15,14 @@ import { getModuleOnDeckByType, getModuleHasLabware, getMagnetLabwareEngageHeight as getMagnetLabwareEngageHeightUtil, + getModulesOnDeckByType, + getModulesHaveLabware, + ModuleAndLabware, } from './utils' -import { Options } from '@opentrons/components' -import { Selector } from '../../types' -import { LabwareNamesByModuleId } from '../../steplist/types' +import type { Options } from '@opentrons/components' +import type { Selector } from '../../types' +import type { LabwareNamesByModuleId } from '../../steplist/types' + export const getLabwareNamesByModuleId: Selector = createSelector( getInitialDeckSetup, getLabwareNicknamesById, @@ -84,16 +88,18 @@ export const getSingleMagneticModuleId: Selector< getModuleOnDeckByType(initialDeckSetup, MAGNETIC_MODULE_TYPE)?.id || null ) -/** Get single temperature module (assumes no multiples) */ -export const getSingleTemperatureModuleId: Selector< - string | null +/** Get all temperature modules */ +export const getTemperatureModuleIds: Selector< + string[] | null > = createSelector( getInitialDeckSetup, initialDeckSetup => - getModuleOnDeckByType(initialDeckSetup, TEMPERATURE_MODULE_TYPE)?.id || null + getModulesOnDeckByType(initialDeckSetup, TEMPERATURE_MODULE_TYPE)?.map( + module => module.id + ) || null ) -/** Get single temperature module (assumes no multiples) */ +/** Get single thermocycler module (assumes no multiples) */ export const getSingleThermocyclerModuleId: Selector< string | null > = createSelector( @@ -111,13 +117,12 @@ export const getMagnetModuleHasLabware: Selector = createSelector( } ) -/** Returns boolean if temperature module has labware */ -export const getTemperatureModuleHasLabware: Selector = createSelector( - getInitialDeckSetup, - initialDeckSetup => { - return getModuleHasLabware(initialDeckSetup, TEMPERATURE_MODULE_TYPE) - } -) +/** Returns all moduleIds and if they have labware for MoaM */ +export const getTemperatureModulesHaveLabware: Selector< + ModuleAndLabware[] +> = createSelector(getInitialDeckSetup, initialDeckSetup => { + return getModulesHaveLabware(initialDeckSetup, TEMPERATURE_MODULE_TYPE) +}) /** Returns boolean if thermocycler module has labware */ export const getThermocyclerModuleHasLabware: Selector = createSelector( diff --git a/protocol-designer/src/ui/modules/utils.ts b/protocol-designer/src/ui/modules/utils.ts index fcd1ddb5f43..e49e8ad7b33 100644 --- a/protocol-designer/src/ui/modules/utils.ts +++ b/protocol-designer/src/ui/modules/utils.ts @@ -20,12 +20,25 @@ export function getModuleOnDeckByType( (moduleOnDeck: ModuleOnDeck) => moduleOnDeck.type === type ) } +export function getModulesOnDeckByType( + initialDeckSetup: InitialDeckSetup, + type: ModuleType +): ModuleOnDeck[] | null | undefined { + return values(initialDeckSetup.modules).filter( + (moduleOnDeck: ModuleOnDeck) => moduleOnDeck.type === type + ) +} export function getLabwareOnModule( initialDeckSetup: InitialDeckSetup, moduleId: string ): LabwareOnDeck | null | undefined { return values(initialDeckSetup.labware).find( - (lab: LabwareOnDeck) => lab.slot === moduleId + (labware: LabwareOnDeck) => + labware.slot === moduleId || + // acccount for adapter! + values(initialDeckSetup.labware).find( + adapter => adapter.id === labware.slot && adapter.slot === moduleId + ) ) } export function getModuleUnderLabware( @@ -81,28 +94,39 @@ export function getModuleLabwareOptions( nicknamesById: Record, type: ModuleType ): Options { - const moduleOnDeck = getModuleOnDeckByType(initialDeckSetup, type) - const labware = - moduleOnDeck && getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) + const labwares = initialDeckSetup.labware + const modulesOnDeck = getModulesOnDeckByType(initialDeckSetup, type) const module = getModuleShortNames(type) let options: Options = [] - if (moduleOnDeck) { - if (labware) { - options = [ - { - name: `${nicknamesById[labware.id]} in ${module}`, + if (modulesOnDeck != null) { + options = modulesOnDeck.map(moduleOnDeck => { + const labware = getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) + if (labware) { + const labwareOnAdapterId = + labwares[labware.id] != null ? labwares[labware.id].id : null + if (labwareOnAdapterId != null) { + return { + name: `${nicknamesById[labwareOnAdapterId]} in ${ + nicknamesById[labware.id] + } in ${module} in slot ${moduleOnDeck.slot}`, + value: moduleOnDeck.id, + } + } else { + return { + name: `${nicknamesById[labware.id]} in ${module} in slot ${ + moduleOnDeck.slot + }`, + value: moduleOnDeck.id, + } + } + } else { + return { + name: `No labware in ${module} in slot ${moduleOnDeck.slot}`, value: moduleOnDeck.id, - }, - ] - } else { - options = [ - { - name: `${module} No labware on module`, - value: moduleOnDeck.id, - }, - ] - } + } + } + }) } return options @@ -116,6 +140,29 @@ export function getModuleHasLabware( moduleOnDeck && getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) return Boolean(moduleOnDeck) && Boolean(labware) } + +export interface ModuleAndLabware { + moduleId: string + hasLabware: boolean +} + +export function getModulesHaveLabware( + initialDeckSetup: InitialDeckSetup, + type: ModuleType +): ModuleAndLabware[] { + const modulesOnDeck = getModulesOnDeckByType(initialDeckSetup, type) + const moduleAndLabware: ModuleAndLabware[] = [] + modulesOnDeck?.forEach(module => { + const labwareHasModule = getLabwareOnModule(initialDeckSetup, module.id) + + moduleAndLabware.push({ + moduleId: module.id, + hasLabware: labwareHasModule != null, + }) + }) + return moduleAndLabware +} + export const getMagnetLabwareEngageHeight = ( initialDeckSetup: InitialDeckSetup, magnetModuleId: string | null diff --git a/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts index 2a087d4ac31..56046da6a98 100644 --- a/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts +++ b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts @@ -19,15 +19,13 @@ beforeEach(() => { vi.mocked(addHint).mockReturnValue('addHintReturnValue' as any) vi.mocked(labwareIngredSelectors.getDeckHasLiquid).mockReturnValue(true) vi.mocked(uiModuleSelectors.getMagnetModuleHasLabware).mockReturnValue(false) - vi.mocked(uiModuleSelectors.getTemperatureModuleHasLabware).mockReturnValue( - false + vi.mocked(uiModuleSelectors.getTemperatureModulesHaveLabware).mockReturnValue( + [] ) vi.mocked(uiModuleSelectors.getThermocyclerModuleHasLabware).mockReturnValue( false ) - vi.mocked(uiModuleSelectors.getSingleTemperatureModuleId).mockReturnValue( - null - ) + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue(null) vi.mocked(uiModuleSelectors.getSingleThermocyclerModuleId).mockReturnValue( null ) @@ -89,10 +87,11 @@ describe('addAndSelectStepWithHints', () => { stepType: 'magnet' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: null, getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: [], }, }, { @@ -100,10 +99,13 @@ describe('addAndSelectStepWithHints', () => { stepType: 'temperature' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [ + { moduleId: 'mockId', hasLabware: false }, + ], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: 'something', getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: ['mockId'], }, }, { @@ -111,10 +113,11 @@ describe('addAndSelectStepWithHints', () => { stepType: 'temperature' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: null, getSingleThermocyclerModuleId: 'something', + getTemperatureModuleIds: [], }, }, ].forEach(({ testName, stepType, selectorValues }) => { @@ -123,14 +126,14 @@ describe('addAndSelectStepWithHints', () => { selectorValues.getMagnetModuleHasLabware ) vi.mocked( - uiModuleSelectors.getTemperatureModuleHasLabware - ).mockReturnValue(selectorValues.getTemperatureModuleHasLabware) + uiModuleSelectors.getTemperatureModulesHaveLabware + ).mockReturnValue(selectorValues.getTemperatureModulesHaveLabware) vi.mocked( uiModuleSelectors.getThermocyclerModuleHasLabware ).mockReturnValue(selectorValues.getThermocyclerModuleHasLabware) - vi.mocked( - uiModuleSelectors.getSingleTemperatureModuleId - ).mockReturnValue(selectorValues.getSingleTemperatureModuleId) + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue( + selectorValues.getTemperatureModuleIds + ) vi.mocked( uiModuleSelectors.getSingleThermocyclerModuleId ).mockReturnValue(selectorValues.getSingleThermocyclerModuleId) @@ -159,4 +162,56 @@ describe('addAndSelectStepWithHints', () => { }) }) }) + describe('ADD_HINT "multiple_modules_without_labware"', () => { + ;[ + { + testName: 'temperature step, when temperature module has no labware', + stepType: 'temperature' as StepType, + selectorValues: { + getMagnetModuleHasLabware: false, + getTemperatureModulesHaveLabware: [ + { moduleId: 'mockId', hasLabware: false }, + { moduleId: 'mockId2', hasLabware: true }, + ], + getThermocyclerModuleHasLabware: false, + getSingleTemperatureModuleId: 'something', + getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: ['mockId', 'mockId2'], + }, + }, + ].forEach(({ testName, stepType, selectorValues }) => { + it(`should be dispatched (after addStep thunk is dispatched) for ${testName}`, () => { + vi.mocked( + uiModuleSelectors.getTemperatureModulesHaveLabware + ).mockReturnValue(selectorValues.getTemperatureModulesHaveLabware) + + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue( + selectorValues.getTemperatureModuleIds + ) + + const payload = { + stepType, + } + addAndSelectStepWithHints(payload)(dispatch, getState) + expect(vi.mocked(addHint).mock.calls).toEqual([ + ['multiple_modules_without_labware'], + ]) + expect(dispatch.mock.calls).toEqual([ + [ + { + type: 'ADD_STEP', + payload: { + id: PRESAVED_STEP_ID, + stepType, + }, + meta: { + robotStateTimeline: 'mockGetRobotStateTimelineValue', + }, + }, + ], + ['addHintReturnValue'], + ]) + }) + }) + }) }) diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.ts b/protocol-designer/src/ui/steps/actions/thunks/index.ts index c6d8be20159..9cc31de8ab8 100644 --- a/protocol-designer/src/ui/steps/actions/thunks/index.ts +++ b/protocol-designer/src/ui/steps/actions/thunks/index.ts @@ -40,18 +40,21 @@ export const addAndSelectStepWithHints: (arg: { const magnetModuleHasLabware = uiModuleSelectors.getMagnetModuleHasLabware( state ) - const temperatureModuleHasLabware = uiModuleSelectors.getTemperatureModuleHasLabware( + const temperatureModulesHaveLabware = uiModuleSelectors.getTemperatureModulesHaveLabware( state ) const thermocyclerModuleHasLabware = uiModuleSelectors.getThermocyclerModuleHasLabware( state ) - const temperatureModuleOnDeck = uiModuleSelectors.getSingleTemperatureModuleId( + const temperatureModuleOnDeck = uiModuleSelectors.getTemperatureModuleIds( state ) const thermocyclerModuleOnDeck = uiModuleSelectors.getSingleThermocyclerModuleId( state ) + const tempHasNoLabware = temperatureModulesHaveLabware.some( + module => !module.hasLabware + ) // TODO: Ian 2019-01-17 move out to centralized step info file - see #2926 const stepNeedsLiquid = ['mix', 'moveLiquid'].includes(payload.stepType) const stepMagnetNeedsLabware = ['magnet'].includes(payload.stepType) @@ -59,15 +62,17 @@ export const addAndSelectStepWithHints: (arg: { const stepModuleMissingLabware = (stepMagnetNeedsLabware && !magnetModuleHasLabware) || (stepTemperatureNeedsLabware && - ((temperatureModuleOnDeck && !temperatureModuleHasLabware) || - (thermocyclerModuleOnDeck && !thermocyclerModuleHasLabware))) + thermocyclerModuleOnDeck && + !thermocyclerModuleHasLabware) || + (temperatureModuleOnDeck?.length === 1 && tempHasNoLabware) if (stepNeedsLiquid && !deckHasLiquid) { dispatch(tutorialActions.addHint('add_liquids_and_labware')) } - if (stepModuleMissingLabware) { dispatch(tutorialActions.addHint('module_without_labware')) + } else if (temperatureModuleOnDeck && tempHasNoLabware) { + dispatch(tutorialActions.addHint('multiple_modules_without_labware')) } } export interface ReorderSelectedStepAction { diff --git a/protocol-designer/src/ui/steps/selectors.ts b/protocol-designer/src/ui/steps/selectors.ts index f9a228366d3..8ed2eeb20dd 100644 --- a/protocol-designer/src/ui/steps/selectors.ts +++ b/protocol-designer/src/ui/steps/selectors.ts @@ -136,10 +136,11 @@ export const getHoveredStepLabware = createSelector( // only 1 labware return [stepArgs.labware] } - // @ts-expect-error(sa, 2021-6-15): type narrow stepArgs.module - if (stepArgs.module) { - // @ts-expect-error(sa, 2021-6-15): this expect error should not be necessary after type narrowing above - const labware = getLabwareOnModule(initialDeckState, stepArgs.module) + if ('module' in stepArgs) { + const labware = getLabwareOnModule( + initialDeckState, + stepArgs.module ?? '' + ) return labware ? [labware.id] : [] } @@ -150,8 +151,9 @@ export const getHoveredStepLabware = createSelector( // step types that have no labware that gets highlighted if (!(stepArgs.commandCreatorFnName === 'delay')) { - // TODO Ian 2018-05-08 use assert here console.warn( + // @ts-expect-error: should only reach this warning when new step is added and + // highlighted wells is not yet implemented `getHoveredStepLabware does not support step type "${stepArgs.commandCreatorFnName}"` ) } From cc084a47d1322bd7152416c86495e45dc24a924e Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:26:44 -0400 Subject: [PATCH 16/49] fix(shared-data): format rtp float and int choices to include suffix (#14842) closes AUTH-311 --- .../formatRunTimeParameterValue.test.ts | 60 +++++++++++++++++-- .../formatRunTimeParameterDefaultValue.ts | 22 ++++--- .../js/helpers/formatRunTimeParameterValue.ts | 21 ++++--- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts index a405d5845d3..2f78d99e11c 100644 --- a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts @@ -41,11 +41,11 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { expect(result).toEqual('6.5 mL') }) - it('should return value with suffix when type is str', () => { + it('should return value when type is str', () => { const mockData = { value: 'left', displayName: 'pipette mount', - variableName: 'mont', + variableName: 'mount', description: 'pipette mount', type: 'str', choices: [ @@ -64,7 +64,59 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { expect(result).toEqual('Left') }) - it('should return value with suffix when type is boolean true', () => { + it('should return value when type is int choice with suffix', () => { + const mockData = { + value: 5, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'int', + suffix: 'mL', + min: 1, + max: 10, + choices: [ + { + displayName: 'one', + value: 1, + }, + { + displayName: 'six', + value: 6, + }, + ], + default: 5, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is float choice with suffix', () => { + const mockData = { + value: 5.0, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'float', + suffix: 'mL', + min: 1.0, + max: 10.0, + choices: [ + { + displayName: 'one', + value: 1.0, + }, + { + displayName: 'six', + value: 6.0, + }, + ], + default: 5.0, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is boolean true', () => { const mockData = { value: true, displayName: 'Deactivate Temperatures', @@ -77,7 +129,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { expect(result).toEqual('On') }) - it('should return value with suffix when type is boolean false', () => { + it('should return value when type is boolean false', () => { const mockData = { value: false, displayName: 'Dry Run', diff --git a/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts index 78de4e78f02..aa7d16a256f 100644 --- a/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts @@ -9,6 +9,18 @@ export const formatRunTimeParameterDefaultValue = ( 'suffix' in runTimeParameter && runTimeParameter.suffix != null ? runTimeParameter.suffix : null + + if ('choices' in runTimeParameter && runTimeParameter.choices != null) { + const choice = runTimeParameter.choices.find( + choice => choice.value === defaultValue + ) + if (choice != null) { + return suffix != null + ? `${choice.displayName} ${suffix}` + : choice.displayName + } + } + switch (type) { case 'int': case 'float': @@ -21,15 +33,7 @@ export const formatRunTimeParameterDefaultValue = ( } else { return Boolean(defaultValue) ? 'On' : 'Off' } - case 'str': - if ('choices' in runTimeParameter && runTimeParameter.choices != null) { - const choice = runTimeParameter.choices.find( - choice => choice.value === defaultValue - ) - if (choice != null) { - return choice.displayName - } - } + default: break } return '' diff --git a/shared-data/js/helpers/formatRunTimeParameterValue.ts b/shared-data/js/helpers/formatRunTimeParameterValue.ts index 0aa0b72a194..a75bee5fd68 100644 --- a/shared-data/js/helpers/formatRunTimeParameterValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterValue.ts @@ -9,6 +9,17 @@ export const formatRunTimeParameterValue = ( 'suffix' in runTimeParameter && runTimeParameter.suffix != null ? runTimeParameter.suffix : null + + if ('choices' in runTimeParameter && runTimeParameter.choices != null) { + const choice = runTimeParameter.choices.find( + choice => choice.value === value + ) + if (choice != null) { + return suffix != null + ? `${choice.displayName} ${suffix}` + : choice.displayName + } + } switch (type) { case 'int': case 'float': @@ -18,15 +29,7 @@ export const formatRunTimeParameterValue = ( case 'bool': { return Boolean(value) ? t('on') : t('off') } - case 'str': - if ('choices' in runTimeParameter && runTimeParameter.choices != null) { - const choice = runTimeParameter.choices.find( - choice => choice.value === value - ) - if (choice != null) { - return choice.displayName - } - } + default: break } return '' From 19d88ce2a61c9861b34f028031b728907afa31bd Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 9 Apr 2024 11:07:52 -0400 Subject: [PATCH 17/49] fix(protocol-designer): magnetic form correct engage height ranges (#14841) closes RQA-2490, AUTH-269 --- .../StepEditForm/StepEditForm.module.css | 27 ----- .../StepEditForm/forms/MagnetForm.tsx | 57 +++++----- .../forms/__tests__/HeaterShakerForm.test.tsx | 2 +- .../forms/__tests__/MagnetForm.test.tsx | 97 +++++++++++++++++- protocol-designer/src/constants.ts | 8 +- .../modules/engage_height_animation_gen1.gif | Bin 22908 -> 0 bytes .../modules/engage_height_animation_gen2.gif | Bin 23678 -> 0 bytes .../modules/engage_height_static_gen1.png | Bin 7597 -> 0 bytes .../modules/engage_height_static_gen2.png | Bin 8173 -> 0 bytes .../src/localization/en/application.json | 2 + 10 files changed, 131 insertions(+), 62 deletions(-) delete mode 100644 protocol-designer/src/images/modules/engage_height_animation_gen1.gif delete mode 100644 protocol-designer/src/images/modules/engage_height_animation_gen2.gif delete mode 100644 protocol-designer/src/images/modules/engage_height_static_gen1.png delete mode 100644 protocol-designer/src/images/modules/engage_height_static_gen2.png diff --git a/protocol-designer/src/components/StepEditForm/StepEditForm.module.css b/protocol-designer/src/components/StepEditForm/StepEditForm.module.css index 5e27c4358fb..439dccbdf8c 100644 --- a/protocol-designer/src/components/StepEditForm/StepEditForm.module.css +++ b/protocol-designer/src/components/StepEditForm/StepEditForm.module.css @@ -269,33 +269,6 @@ and when that is implemented. margin: 1rem 0 2rem 14rem; } -.engage_height_diagram { - width: 90%; - padding-top: calc(40 / 540 * 90%); - background-repeat: no-repeat; - background-size: cover; - - &:hover { - cursor: pointer; - } -} - -.engage_height_diagram_gen1 { - background-image: url('../../images/modules/engage_height_static_gen1.png'); - - &:hover { - background-image: url('../../images/modules/engage_height_animation_gen1.gif'); - } -} - -.engage_height_diagram_gen2 { - background-image: url('../../images/modules/engage_height_static_gen2.png'); - - &:hover { - background-image: url('../../images/modules/engage_height_animation_gen2.gif'); - } -} - .tc_step_group { margin: 1rem 0; } diff --git a/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx b/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx index 8873c10eb52..1976767e7e5 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx @@ -1,27 +1,31 @@ import * as React from 'react' -import cx from 'classnames' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { FormGroup } from '@opentrons/components' import { MAGNETIC_MODULE_V1 } from '@opentrons/shared-data' import { selectors as uiModuleSelectors } from '../../../ui/modules' -import { selectors as stepFormSelectors } from '../../../step-forms' -import { maskField } from '../../../steplist/fieldLevel' +import { getModuleEntities } from '../../../step-forms/selectors' +import { + MAX_ENGAGE_HEIGHT_V1, + MAX_ENGAGE_HEIGHT_V2, + MIN_ENGAGE_HEIGHT_V1, + MIN_ENGAGE_HEIGHT_V2, +} from '../../../constants' import { TextField, RadioGroupField } from '../fields' -import styles from '../StepEditForm.module.css' +import type { StepFormProps } from '../types' -import { StepFormProps } from '../types' +import styles from '../StepEditForm.module.css' -export const MagnetForm = (props: StepFormProps): JSX.Element => { +export function MagnetForm(props: StepFormProps): JSX.Element { const moduleLabwareOptions = useSelector( uiModuleSelectors.getMagneticLabwareOptions ) + const moduleEntities = useSelector(getModuleEntities) const { t } = useTranslation(['application', 'form']) + const { propsForFields, formData } = props + const { magnetAction, moduleId } = formData - const moduleEntities = useSelector(stepFormSelectors.getModuleEntities) - const { magnetAction, moduleId } = props.formData - const moduleModel = moduleId ? moduleEntities[moduleId]?.model : null - + const moduleModel = moduleEntities[moduleId].model const moduleOption: string | null | undefined = moduleLabwareOptions[0] ? moduleLabwareOptions[0].name : 'No magnetic module' @@ -29,12 +33,21 @@ export const MagnetForm = (props: StepFormProps): JSX.Element => { const defaultEngageHeight = useSelector( uiModuleSelectors.getMagnetLabwareEngageHeight ) - - const engageHeightCaption = defaultEngageHeight - ? `Recommended: ${String(maskField('engageHeight', defaultEngageHeight))}` - : null - - const { propsForFields } = props + const engageHeightMinMax = + moduleModel === MAGNETIC_MODULE_V1 + ? t('magnet_height_caption', { + low: MIN_ENGAGE_HEIGHT_V1, + high: MAX_ENGAGE_HEIGHT_V1, + }) + : t('magnet_height_caption', { + low: MIN_ENGAGE_HEIGHT_V2, + high: MAX_ENGAGE_HEIGHT_V2, + }) + const engageHeightDefault = + defaultEngageHeight != null + ? t('magnet_recommended', { default: defaultEngageHeight }) + : '' + const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}` return (
    @@ -91,18 +104,6 @@ export const MagnetForm = (props: StepFormProps): JSX.Element => { )}
    - {magnetAction === 'engage' && ( -
    -
    -
    - )}
    ) } diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx index 6ddefc3af74..dbc5bb5a408 100644 --- a/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx @@ -31,7 +31,7 @@ vi.mock('../../fields', async importOriginal => { const render = (props: React.ComponentProps) => { return renderWithProviders(, { - i18nInstance: i18n as any, + i18nInstance: i18n, })[0] } diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx index 736294018a9..34146989405 100644 --- a/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx @@ -1,5 +1,98 @@ -import { describe, it } from 'vitest' +import * as React from 'react' +import { describe, it, afterEach, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { getMagneticLabwareOptions } from '../../../../ui/modules/selectors' +import { getModuleEntities } from '../../../../step-forms/selectors' +import { getMagnetLabwareEngageHeight } from '../../../../ui/modules/utils' +import { MagnetForm } from '../MagnetForm' + +vi.mock('../../../../ui/modules/utils') +vi.mock('../../../../ui/modules/selectors') +vi.mock('../../../../step-forms/selectors') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} describe('MagnetForm', () => { - it.todo('replace deprecated enzyme test') + let props: React.ComponentProps + + beforeEach(() => { + props = { + formData: { + id: 'magnet', + stepType: 'magnet', + moduleId: 'magnetId', + magnetAction: 'engage', + } as any, + focusHandlers: { + blur: vi.fn(), + focus: vi.fn(), + dirtyFields: [], + focusedField: null, + }, + propsForFields: { + magnetAction: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'magnetAction', + updateValue: vi.fn(), + value: 'engage', + }, + engageHeight: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'engage height', + updateValue: vi.fn(), + value: 10, + }, + }, + } + vi.mocked(getMagneticLabwareOptions).mockReturnValue([ + { name: 'mock name', value: 'mockValue' }, + ]) + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV2', + type: 'magneticModuleType', + }, + }) + vi.mocked(getMagnetLabwareEngageHeight).mockReturnValue(null) + }) + afterEach(() => { + vi.restoreAllMocks() + cleanup() + }) + + it('renders the text and radio buttons for v2', () => { + render(props) + screen.getByText('magnet') + screen.getByText('module') + screen.getByText('mock name') + screen.getByText('Magnet action') + const engage = screen.getByText('Engage') + screen.getByText('Disengage') + fireEvent.click(engage) + screen.getByText('Must be between -2.5 to 25.') + }) + it('renders the input text for v1', () => { + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV1', + type: 'magneticModuleType', + }, + }) + render(props) + screen.getByText('Must be between 0 to 45.') + }) }) diff --git a/protocol-designer/src/constants.ts b/protocol-designer/src/constants.ts index bae70d17d7f..b92192565c2 100644 --- a/protocol-designer/src/constants.ts +++ b/protocol-designer/src/constants.ts @@ -65,10 +65,10 @@ export const DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP = 0 export const DEFAULT_DELAY_SECONDS = 1 export const DEFAULT_WELL_ORDER_FIRST_OPTION: 't2b' = 't2b' export const DEFAULT_WELL_ORDER_SECOND_OPTION: 'l2r' = 'l2r' -export const MIN_ENGAGE_HEIGHT_V1 = -5 -export const MAX_ENGAGE_HEIGHT_V1 = 40 -export const MIN_ENGAGE_HEIGHT_V2 = -4 -export const MAX_ENGAGE_HEIGHT_V2 = 19 +export const MIN_ENGAGE_HEIGHT_V1 = 0 +export const MAX_ENGAGE_HEIGHT_V1 = 45 +export const MIN_ENGAGE_HEIGHT_V2 = -2.5 +export const MAX_ENGAGE_HEIGHT_V2 = 25 export const MIN_TEMP_MODULE_TEMP = 4 export const MAX_TEMP_MODULE_TEMP = 95 export const MIN_HEATER_SHAKER_MODULE_TEMP = 37 diff --git a/protocol-designer/src/images/modules/engage_height_animation_gen1.gif b/protocol-designer/src/images/modules/engage_height_animation_gen1.gif deleted file mode 100644 index eb0bedae5735d1b76b2cbc9ed83827b5716febc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22908 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 diff --git a/protocol-designer/src/images/modules/engage_height_animation_gen2.gif b/protocol-designer/src/images/modules/engage_height_animation_gen2.gif deleted file mode 100644 index 2865ccb11181a39bbade818e894cdd5367f91997..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23678 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 diff --git a/protocol-designer/src/images/modules/engage_height_static_gen1.png b/protocol-designer/src/images/modules/engage_height_static_gen1.png deleted file mode 100644 index 1bb216a5f06ebd98f17bae5907beaab89748ada0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7597 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 z 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 F{y+YP)z<(3 diff --git a/protocol-designer/src/images/modules/engage_height_static_gen2.png b/protocol-designer/src/images/modules/engage_height_static_gen2.png deleted file mode 100644 index 9e9e163371b791d0062e973e48f0b82876288de0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8173 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 E0oZ2>)&Kwi diff --git a/protocol-designer/src/localization/en/application.json b/protocol-designer/src/localization/en/application.json index 79625a33d51..7943a006e6f 100644 --- a/protocol-designer/src/localization/en/application.json +++ b/protocol-designer/src/localization/en/application.json @@ -15,6 +15,8 @@ "update": "UPDATE", "updated": "UPDATED", "pipettes": "Pipettes", + "magnet_height_caption": "Must be between {{low}} to {{high}}.", + "magnet_recommended": "The recommended height is {{default}}", "networking": { "generic_verification_failure": "Something went wrong with your unique link. Fill out the form below to have a new one sent to your email. Please contact the Opentrons Support team if you require further help.", "unauthorized_verification_failure": "This unique link has expired and is no longer valid, to have a new link sent to your email, fill out the form below.", From 1819b8c90b8440d6d896f4315380651f1880989c Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 9 Apr 2024 11:58:06 -0400 Subject: [PATCH 18/49] feat(api): Pause when `pick_up_tip()` errors in a Python protocol (#14753) --- .../protocol_api/core/engine/instrument.py | 7 +- .../protocol_engine/actions/__init__.py | 2 + .../protocol_engine/actions/actions.py | 21 + .../protocol_engine/clients/sync_client.py | 23 + .../protocol_engine/clients/transports.py | 115 ++++- .../execution/command_executor.py | 1 + .../protocol_engine/execution/queue_worker.py | 3 + .../protocol_engine/protocol_engine.py | 81 +++- .../protocol_engine/state/commands.py | 10 + .../opentrons/protocol_engine/state/state.py | 55 ++- .../opentrons/protocol_engine/state/tips.py | 32 +- .../protocol_runner/legacy_command_mapper.py | 1 + .../core/engine/test_instrument_core.py | 2 +- .../execution/test_command_executor.py | 1 + .../state/test_command_state.py | 361 ++++++++++++++- .../state/test_command_store_old.py | 425 +----------------- .../state/test_command_view_old.py | 2 + .../protocol_engine/state/test_state_store.py | 42 +- .../protocol_engine/test_protocol_engine.py | 102 +++++ .../test_legacy_command_mapper.py | 2 + 20 files changed, 810 insertions(+), 478 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 9c88a4f7ecb..485f45d0e94 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -408,13 +408,18 @@ def pick_up_tip( well_name=well_name, well_location=well_location, ) - self._engine_client.pick_up_tip( + + self._engine_client.pick_up_tip_wait_for_recovery( pipette_id=self._pipette_id, labware_id=labware_id, well_name=well_name, well_location=well_location, ) + # Set the "last location" unconditionally, even if the command failed + # and was recovered from and we don't know if the pipette is physically here. + # This isn't used for path planning, but rather for implicit destination + # selection like in `pipette.aspirate(location=None)`. self._protocol_core.set_last_location(location=location, mount=self.get_mount()) def drop_tip( diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index b1181e6a50e..ac3fc653976 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -11,6 +11,7 @@ PauseAction, PauseSource, StopAction, + ResumeFromRecoveryAction, FinishAction, HardwareStoppedAction, QueueCommandAction, @@ -38,6 +39,7 @@ "PlayAction", "PauseAction", "StopAction", + "ResumeFromRecoveryAction", "FinishAction", "HardwareStoppedAction", "QueueCommandAction", diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index d5c6bb49abc..ee36e76f7de 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -154,11 +154,32 @@ class FailCommandAction: """ command_id: str + """The command to fail.""" + error_id: str + """An ID to assign to the command's error. + + Must be unique to this occurrence of the error. + """ + failed_at: datetime + """When the command failed.""" + error: EnumeratedError + """The underlying exception that caused this command to fail.""" + notes: List[CommandNote] + """Overwrite the command's `.notes` with these.""" + type: ErrorRecoveryType + """How this error should be handled in the context of the overall run.""" + + # This is a quick hack so FailCommandAction handlers can get the params of the + # command that failed. We probably want this to be a new "failure details" + # object instead, similar to how succeeded commands can send a "private result" + # to Protocol Engine internals. + running_command: Command + """The command to fail, in its prior `running` state.""" @dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index f9c9e2ee6c6..f95611c1b4c 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -296,6 +296,29 @@ def pick_up_tip( return cast(commands.PickUpTipResult, result) + def pick_up_tip_wait_for_recovery( + self, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: WellLocation, + ) -> commands.PickUpTip: + """Execute a PickUpTip, wait for any error recovery, and return it. + + Note that the returned command will not necessarily have a `result`. + """ + request = commands.PickUpTipCreate( + params=commands.PickUpTipParams( + pipetteId=pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + ) + ) + command = self._transport.execute_command_wait_for_recovery(request=request) + + return cast(commands.PickUpTip, command) + def drop_tip( self, pipette_id: str, diff --git a/api/src/opentrons/protocol_engine/clients/transports.py b/api/src/opentrons/protocol_engine/clients/transports.py index 270599ff469..6de08db97ed 100644 --- a/api/src/opentrons/protocol_engine/clients/transports.py +++ b/api/src/opentrons/protocol_engine/clients/transports.py @@ -1,15 +1,28 @@ """A helper for controlling a `ProtocolEngine` without async/await.""" from asyncio import AbstractEventLoop, run_coroutine_threadsafe -from typing import Any, overload +from typing import Any, Final, overload from typing_extensions import Literal from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.labware.labware_definition import LabwareDefinition + from ..protocol_engine import ProtocolEngine from ..errors import ProtocolCommandFailedError +from ..error_recovery_policy import ErrorRecoveryType from ..state import StateView -from ..commands import CommandCreate, CommandResult +from ..commands import Command, CommandCreate, CommandResult, CommandStatus + + +class RunStoppedBeforeCommandError(RuntimeError): + """Raised if the ProtocolEngine was stopped before a command could start.""" + + def __init__(self, command: Command) -> None: + self._command = command + super().__init__( + f"The run was stopped" + f" before {command.commandType} command {command.id} could execute." + ) class ChildThreadTransport: @@ -30,8 +43,10 @@ def __init__(self, engine: ProtocolEngine, loop: AbstractEventLoop) -> None: want to synchronously access it. loop: The event loop that `engine` is running in (in the other thread). """ - self._engine = engine - self._loop = loop + # We might access these from different threads, + # so let's make them Final for (shallow) immutability. + self._engine: Final = engine + self._loop: Final = loop @property def state(self) -> StateView: @@ -39,7 +54,11 @@ def state(self) -> StateView: return self._engine.state_view def execute_command(self, request: CommandCreate) -> CommandResult: - """Execute a ProtocolEngine command, blocking until the command completes. + """Execute a ProtocolEngine command. + + This blocks until the command completes. If the command fails, this will always + raise the failure as an exception--even if ProtocolEngine deemed the failure + recoverable. Args: request: The ProtocolEngine command request @@ -48,8 +67,11 @@ def execute_command(self, request: CommandCreate) -> CommandResult: The command's result data. Raises: - ProtocolEngineError: if the command execution is not successful, - the specific error that cause the command to fail is raised. + ProtocolEngineError: If the command execution was not successful, + the specific error that caused the command to fail is raised. + + If the run was stopped before the command could complete, that's + also signaled as this exception. """ command = run_coroutine_threadsafe( self._engine.add_and_execute_command(request=request), @@ -64,21 +86,76 @@ def execute_command(self, request: CommandCreate) -> CommandResult: message=f"{error.errorType}: {error.detail}", ) - # FIXME(mm, 2023-04-10): This assert can easily trigger from this sequence: - # - # 1. The engine is paused. - # 2. The user's Python script calls this method to start a new command, - # which remains `queued` because of the pause. - # 3. The engine is stopped. - # - # The returned command will be `queued`, so it won't have a result. - # - # We need to figure out a proper way to report this condition to callers - # so they correctly interpret it as an intentional stop, not an internal error. - assert command.result is not None, f"Expected Command {command} to have result" + if command.result is None: + # This can happen with a certain pause timing: + # + # 1. The engine is paused. + # 2. The user's Python script calls this method to start a new command, + # which remains `queued` because of the pause. + # 3. The engine is stopped. The returned command will be `queued` + # and won't have a result. + raise RunStoppedBeforeCommandError(command) return command.result + def execute_command_wait_for_recovery(self, request: CommandCreate) -> Command: + """Execute a ProtocolEngine command, including error recovery. + + This blocks until the command completes. Additionally, if the command fails, + this will continue to block until its error recovery has been completed. + + Args: + request: The ProtocolEngine command request. + + Returns: + The command. If error recovery happened for it, the command will be + reported here as failed. + + Raises: + ProtocolEngineError: If the command failed, *and* the failure was not + recovered from. + + If the run was stopped before the command could complete, that's + also signalled as this exception. + """ + + async def run_in_pe_thread() -> Command: + command = await self._engine.add_and_execute_command_wait_for_recovery( + request=request + ) + + if command.error is not None: + error_was_recovered_from = ( + self._engine.state_view.commands.get_error_recovery_type(command.id) + == ErrorRecoveryType.WAIT_FOR_RECOVERY + ) + if not error_was_recovered_from: + error = command.error + # TODO: this needs to have an actual code + raise ProtocolCommandFailedError( + original_error=error, + message=f"{error.errorType}: {error.detail}", + ) + + elif command.status == CommandStatus.QUEUED: + # This can happen with a certain pause timing: + # + # 1. The engine is paused. + # 2. The user's Python script calls this method to start a new command, + # which remains `queued` because of the pause. + # 3. The engine is stopped. The returned command will be `queued`, + # and won't have a result. + raise RunStoppedBeforeCommandError(command) + + return command + + command = run_coroutine_threadsafe( + run_in_pe_thread(), + loop=self._loop, + ).result() + + return command + @overload def call_method( self, diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index d44d37f5641..9488d1719e9 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -167,6 +167,7 @@ async def execute(self, command_id: str) -> None: FailCommandAction( error=error, command_id=running_command.id, + running_command=running_command, error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), notes=note_tracker.get_notes(), diff --git a/api/src/opentrons/protocol_engine/execution/queue_worker.py b/api/src/opentrons/protocol_engine/execution/queue_worker.py index c1ba60eb143..179880c03e9 100644 --- a/api/src/opentrons/protocol_engine/execution/queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/queue_worker.py @@ -72,6 +72,9 @@ async def _run_commands(self) -> None: command_id = await self._state_store.wait_for( condition=self._state_store.commands.get_next_to_execute ) + # Assert for type hinting. This is valid because the wait_for() above + # only returns when the value is truthy. + assert command_id is not None except RunStoppedError: # There are no more commands that we should execute, either because the run has # completed on its own, or because a client requested it to stop. diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 8e23c08013f..bd995f4339a 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -234,7 +234,10 @@ async def add_and_execute_command( the command in state. Returns: - The command. If the command completed, it will be succeeded or failed. + The command. + + If the command completed, it will be succeeded or failed. + If the engine was stopped before it reached the command, the command will be queued. """ @@ -242,6 +245,34 @@ async def add_and_execute_command( await self.wait_for_command(command.id) return self._state_store.commands.get(command.id) + async def add_and_execute_command_wait_for_recovery( + self, request: commands.CommandCreate + ) -> commands.Command: + """Like `add_and_execute_command()`, except wait for error recovery. + + Unlike `add_and_execute_command()`, if the command fails, this will not + immediately return the failed command. Instead, if the error is recoverable, + it will wait until error recovery has completed (e.g. when some other task + calls `self.resume_from_recovery()`). + + Returns: + The command. + + If the command completed, it will be succeeded or failed. If it failed + and then its failure was recovered from, it will still be failed. + + If the engine was stopped before it reached the command, + the command will be queued. + """ + queued_command = self.add_command(request) + await self.wait_for_command(command_id=queued_command.id) + completed_command = self._state_store.commands.get(queued_command.id) + await self._state_store.wait_for_not( + self.state_view.commands.get_recovery_in_progress_for_command, + queued_command.id, + ) + return completed_command + def estop( self, # TODO(mm, 2024-03-26): Maintenance runs are a robot-server concept that @@ -251,6 +282,15 @@ def estop( ) -> None: """Signal to the engine that an estop event occurred. + If an estop happens while the robot is moving, lower layers physically stop + motion and raise the event as an exception, which fails the Protocol Engine + command. No action from the `ProtocolEngine` caller is needed to handle that. + + However, if an estop happens in between commands, or in the middle of + a command like `comment` or `waitForDuration` that doesn't access the hardware, + `ProtocolEngine` needs to be told about it so it can treat it as a fatal run + error and stop executing more commands. This method is how to do that. + If there are any queued commands for the engine, they will be marked as failed due to the estop event. If there aren't any queued commands *and* this is a maintenance run (which has commands queued one-by-one), @@ -261,15 +301,27 @@ def estop( """ if self._state_store.commands.get_is_stopped(): return - - current_id = ( + running_or_next_queued_id = ( self._state_store.commands.get_running_command_id() or self._state_store.commands.get_queue_ids().head(None) + # TODO(mm, 2024-04-02): This logic looks wrong whenever the next queued + # command is a setup command, which is the normal case in maintenance + # runs. Setup commands won't show up in commands.get_queue_ids(). + ) + running_or_next_queued = ( + self._state_store.commands.get(running_or_next_queued_id) + if running_or_next_queued_id is not None + else None ) - if current_id is not None: + if running_or_next_queued_id is not None: + assert running_or_next_queued is not None + fail_action = FailCommandAction( - command_id=current_id, + command_id=running_or_next_queued_id, + # FIXME(mm, 2024-04-02): As of https://github.com/Opentrons/opentrons/pull/14726, + # this action is only legal if the command is running, not queued. + running_command=running_or_next_queued, error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), error=EStopActivatedError(message="Estop Activated"), @@ -278,12 +330,21 @@ def estop( ) self._action_dispatcher.dispatch(fail_action) - # In the case where the running command was a setup command - check if there - # are any pending *run* commands and, if so, clear them all - current_id = self._state_store.commands.get_queue_ids().head(None) - if current_id is not None: + # The FailCommandAction above will have cleared all the queued protocol + # OR setup commands, depending on whether we gave it a protocol or setup + # command. We want both to be cleared in either case. So, do that here. + running_or_next_queued_id = self._state_store.commands.get_queue_ids().head( + None + ) + if running_or_next_queued_id is not None: + running_or_next_queued = self._state_store.commands.get( + running_or_next_queued_id + ) fail_action = FailCommandAction( - command_id=current_id, + command_id=running_or_next_queued_id, + # FIXME(mm, 2024-04-02): As of https://github.com/Opentrons/opentrons/pull/14726, + # this action is only legal if the command is running, not queued. + running_command=running_or_next_queued, error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), error=EStopActivatedError(message="Estop Activated"), diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 2c66e45826d..1ae0cb1ed68 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -178,6 +178,9 @@ 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.""" + finish_error: Optional[ErrorOccurrence] """The error that happened during the post-run finish steps (homing & dropping tips), if any.""" @@ -213,6 +216,7 @@ def __init__( finish_error=None, failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, run_completed_at=None, run_started_at=None, latest_command_hash=None, @@ -300,6 +304,7 @@ def handle_action(self, action: Action) -> None: # noqa: C901 ): if action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY: self._state.queue_status = QueueStatus.AWAITING_RECOVERY + self._state.recovery_target_command_id = action.command_id elif action.type == ErrorRecoveryType.FAIL_RUN: other_command_ids_to_fail = ( self._state.command_history.get_queue_ids() @@ -335,6 +340,7 @@ def handle_action(self, action: Action) -> None: # noqa: C901 elif isinstance(action, ResumeFromRecoveryAction): self._state.queue_status = QueueStatus.RUNNING + self._state.recovery_target_command_id = None elif isinstance(action, StopAction): if not self._state.run_result: @@ -708,6 +714,10 @@ def get_all_commands_final(self) -> bool: return no_command_running and no_command_to_execute + def get_recovery_in_progress_for_command(self, command_id: str) -> bool: + """Return whether the given command failed and its error recovery is in progress.""" + return self._state.recovery_target_command_id == command_id + def raise_fatal_command_error(self) -> None: """Raise the run's fatal command error, if there was one, as an exception. diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index a472b574e6f..6e08bf759c6 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -2,8 +2,8 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial -from typing import Any, Callable, Dict, List, Optional, Sequence, TypeVar +from typing import Callable, Dict, List, Optional, Sequence, TypeVar +from typing_extensions import ParamSpec from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 @@ -30,7 +30,9 @@ from .state_summary import StateSummary from ..types import DeckConfigurationType -ReturnT = TypeVar("ReturnT") + +_ParamsT = ParamSpec("_ParamsT") +_ReturnT = TypeVar("_ReturnT") @dataclass(frozen=True) @@ -210,10 +212,10 @@ def handle_action(self, action: Action) -> None: async def wait_for( self, - condition: Callable[..., Optional[ReturnT]], - *args: Any, - **kwargs: Any, - ) -> ReturnT: + condition: Callable[_ParamsT, _ReturnT], + *args: _ParamsT.args, + **kwargs: _ParamsT.kwargs, + ) -> _ReturnT: """Wait for a condition to become true, checking whenever state changes. If the condition is already true, return immediately. @@ -258,14 +260,43 @@ async def wait_for( Raises: The exception raised by the `condition` function, if any. """ - predicate = partial(condition, *args, **kwargs) - is_done = predicate() - while not is_done: + def predicate() -> _ReturnT: + return condition(*args, **kwargs) + + return await self._wait_for(condition=predicate, truthiness_to_wait_for=True) + + async def wait_for_not( + self, + condition: Callable[_ParamsT, _ReturnT], + *args: _ParamsT.args, + **kwargs: _ParamsT.kwargs, + ) -> _ReturnT: + """Like `wait_for()`, except wait for the condition to become false. + + See the documentation in `wait_for()`, especially the warning about condition + design. + + The advantage of having this separate method over just passing a wrapper lambda + as the condition to `wait_for()` yourself is that wrapper lambdas are hard to + test in the mock-heavy Decoy + Protocol Engine style. + """ + + def predicate() -> _ReturnT: + return condition(*args, **kwargs) + + return await self._wait_for(condition=predicate, truthiness_to_wait_for=False) + + async def _wait_for( + self, condition: Callable[[], _ReturnT], truthiness_to_wait_for: bool + ) -> _ReturnT: + current_value = condition() + + while bool(current_value) != truthiness_to_wait_for: await self._change_notifier.wait() - is_done = predicate() + current_value = condition() - return is_done + return current_value def _get_next_state(self) -> State: """Get a new instance of the state value object.""" diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index a2539ff45e7..f5d68d61ee5 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -7,11 +7,13 @@ from ..actions import ( Action, SucceedCommandAction, + FailCommandAction, ResetTipsAction, ) from ..commands import ( Command, LoadLabwareResult, + PickUpTip, PickUpTipResult, DropTipResult, DropTipInPlaceResult, @@ -20,6 +22,7 @@ PipetteConfigUpdateResultMixin, PipetteNozzleLayoutResultMixin, ) +from ..error_recovery_policy import ErrorRecoveryType from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -71,7 +74,7 @@ def handle_action(self, action: Action) -> None: self._state.channels_by_pipette_id[pipette_id] = config.channels self._state.active_channels_by_pipette_id[pipette_id] = config.channels self._state.nozzle_map_by_pipette_id[pipette_id] = config.nozzle_map - self._handle_command(action.command) + self._handle_succeeded_command(action.command) if isinstance(action.private_result, PipetteNozzleLayoutResultMixin): pipette_id = action.private_result.pipette_id @@ -86,6 +89,9 @@ def handle_action(self, action: Action) -> None: pipette_id ] = self._state.channels_by_pipette_id[pipette_id] + elif isinstance(action, FailCommandAction): + self._handle_failed_command(action) + elif isinstance(action, ResetTipsAction): labware_id = action.labware_id @@ -94,7 +100,7 @@ def handle_action(self, action: Action) -> None: well_name ] = TipRackWellState.CLEAN - def _handle_command(self, command: Command) -> None: + def _handle_succeeded_command(self, command: Command) -> None: if ( isinstance(command.result, LoadLabwareResult) and command.result.definition.parameters.isTiprack @@ -124,6 +130,28 @@ def _handle_command(self, command: Command) -> None: pipette_id = command.params.pipetteId self._state.length_by_pipette_id.pop(pipette_id, None) + def _handle_failed_command( + self, + action: FailCommandAction, + ) -> None: + # If a pickUpTip command fails recoverably, mark the tips as used. This way, + # when the protocol is resumed and the Python Protocol API calls + # `get_next_tip()`, we'll move on to other tips as expected. + # + # We don't attempt this for nonrecoverable errors because maybe the failure + # was due to a bad labware ID or well name. + if ( + isinstance(action.running_command, PickUpTip) + and action.type != ErrorRecoveryType.FAIL_RUN + ): + self._set_used_tips( + pipette_id=action.running_command.params.pipetteId, + labware_id=action.running_command.params.labwareId, + well_name=action.running_command.params.wellName, + ) + # Note: We're logically removing the tip from the tip rack, + # but we're not logically updating the pipette to have that tip on it. + def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index ea212123cb3..e835a6af8e6 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -265,6 +265,7 @@ def map_command( # noqa: C901 results.append( pe_actions.FailCommandAction( command_id=running_command.id, + running_command=running_command, error_id=ModelUtils.generate_id(), failed_at=now, error=LegacyContextCommandError(command_error), 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 3b296067a0d..6ac0e9aaaf0 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 @@ -276,7 +276,7 @@ def test_pick_up_tip( origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), ), - mock_engine_client.pick_up_tip( + mock_engine_client.pick_up_tip_wait_for_recovery( pipette_id="abc123", labware_id="labware-id", well_name="well-name", 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 94b7ad25509..2cd753093f9 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -500,6 +500,7 @@ def _ImplementationCls(self) -> Type[_TestCommandImpl]: action_dispatcher.dispatch( FailCommandAction( command_id="command-id", + running_command=running_command, error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), error=expected_error, 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 001b1b7640c..8f1ea39fc00 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -6,10 +6,14 @@ from datetime import datetime -from opentrons_shared_data.errors import PythonException +import pytest -from opentrons.protocol_engine import actions, commands +from opentrons_shared_data.errors import ErrorCodes, PythonException + +from opentrons.ordered_set import OrderedSet +from opentrons.protocol_engine import actions, commands, errors from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType +from opentrons.protocol_engine.notes.notes import CommandNote from opentrons.protocol_engine.state.commands import CommandStore, CommandView from opentrons.protocol_engine.state.config import Config from opentrons.protocol_engine.types import DeckType @@ -23,6 +27,269 @@ def _make_config() -> Config: ) +@pytest.mark.parametrize("error_recovery_type", ErrorRecoveryType) +def test_command_failure(error_recovery_type: ErrorRecoveryType) -> None: + """It should store an error and mark the command if it fails.""" + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + command_id = "command-id" + command_key = "command-key" + created_at = datetime(year=2021, month=1, day=1) + started_at = datetime(year=2022, month=2, day=2) + failed_at = datetime(year=2023, month=3, day=3) + error_id = "error-id" + notes = [ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ] + + params = commands.CommentParams(message="No comment.") + + subject.handle_action( + actions.QueueCommandAction( + command_id=command_id, + created_at=created_at, + request=commands.CommentCreate(params=params, key=command_key), + request_hash=None, + ) + ) + subject.handle_action( + actions.RunCommandAction(command_id=command_id, started_at=started_at) + ) + subject.handle_action( + actions.FailCommandAction( + command_id=command_id, + running_command=subject_view.get(command_id), + error_id=error_id, + failed_at=failed_at, + error=errors.ProtocolEngineError(message="oh no"), + notes=notes, + type=error_recovery_type, + ) + ) + + expected_error_occurrence = errors.ErrorOccurrence( + id=error_id, + errorType="ProtocolEngineError", + createdAt=failed_at, + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ) + expected_failed_command = commands.Comment( + id=command_id, + key=command_key, + commandType="comment", + createdAt=created_at, + startedAt=started_at, + completedAt=failed_at, + status=commands.CommandStatus.FAILED, + params=params, + result=None, + error=expected_error_occurrence, + notes=notes, + ) + + assert subject_view.get("command-id") == expected_failed_command + + +def test_command_failure_clears_queues() -> None: + """It should clear the command queue on command failure.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), 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_1) + queue_2 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-2" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2) + + run_1 = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_1) + fail_1 = 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=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.FAIL_RUN, + ) + subject.handle_action(fail_1) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.FAILED), + ("command-id-2", commands.CommandStatus.FAILED), + ] + assert subject_view.get_running_command_id() is None + assert subject_view.get_queue_ids() == OrderedSet() + assert subject_view.get_next_to_execute() is None + + +def test_setup_command_failure_only_clears_setup_command_queue() -> None: + """It should clear only the setup command queue for a failed setup command. + + This test queues up a non-setup command followed by two setup commands, + then runs and fails the first setup command. + """ + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), 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_1) + queue_2_setup = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), + intent=commands.CommandIntent.SETUP, + key="command-key-2", + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2_setup) + queue_3_setup = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), + intent=commands.CommandIntent.SETUP, + key="command-key-3", + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-3", + ) + subject.handle_action(queue_3_setup) + + run_2_setup = actions.RunCommandAction( + command_id="command-id-2", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_2_setup) + fail_2_setup = actions.FailCommandAction( + command_id="command-id-2", + running_command=subject_view.get("command-id-2"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.FAIL_RUN, + ) + subject.handle_action(fail_2_setup) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.QUEUED), + ("command-id-2", commands.CommandStatus.FAILED), + ("command-id-3", commands.CommandStatus.FAILED), + ] + assert subject_view.get_running_command_id() is None + + subject.handle_action( + actions.PlayAction(requested_at=datetime.now(), deck_configuration=None) + ) + assert subject_view.get_next_to_execute() == "command-id-1" + + +def test_nonfatal_command_failure() -> None: + """Test the command queue if a command fails recoverably. + + Commands that were after the failed command in the queue should be left in + the queue. + + The queue status should be "awaiting-recovery." + """ + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), 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_1) + queue_2 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-2" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2) + + run_1 = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_1) + fail_1 = 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=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ) + subject.handle_action(fail_1) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.FAILED), + ("command-id-2", commands.CommandStatus.QUEUED), + ] + assert subject_view.get_running_command_id() is None + + def test_error_recovery_type_tracking() -> None: """It should keep track of each failed command's error recovery type.""" subject = CommandStore(config=_make_config(), is_door_open=False) @@ -50,9 +317,11 @@ def test_error_recovery_type_tracking() -> None: subject.handle_action( actions.RunCommandAction(command_id="c1", started_at=datetime.now()) ) + running_command_1 = CommandView(subject.state).get("c1") subject.handle_action( actions.FailCommandAction( command_id="c1", + running_command=running_command_1, error_id="c1-error", failed_at=datetime.now(), error=PythonException(RuntimeError("new sheriff in town")), @@ -63,9 +332,11 @@ def test_error_recovery_type_tracking() -> None: subject.handle_action( actions.RunCommandAction(command_id="c2", started_at=datetime.now()) ) + running_command_2 = CommandView(subject.state).get("c2") subject.handle_action( actions.FailCommandAction( command_id="c2", + running_command=running_command_2, error_id="c2-error", failed_at=datetime.now(), error=PythonException(RuntimeError("new sheriff in town")), @@ -77,3 +348,89 @@ def test_error_recovery_type_tracking() -> None: view = CommandView(subject.state) assert view.get_error_recovery_type("c1") == ErrorRecoveryType.WAIT_FOR_RECOVERY assert view.get_error_recovery_type("c2") == ErrorRecoveryType.FAIL_RUN + + +def test_get_recovery_in_progress_for_command() -> None: + """It should return whether error recovery is in progress for the given command.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + "c1", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_1) + run_1 = actions.RunCommandAction(command_id="c1", started_at=datetime.now()) + subject.handle_action(run_1) + fail_1 = 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_1) + + # c1 failed recoverably and we're currently recovering from it. + assert subject_view.get_recovery_in_progress_for_command("c1") + + resume_from_1_recovery = actions.ResumeFromRecoveryAction() + subject.handle_action(resume_from_1_recovery) + + # c1 failed recoverably, but we've already completed its recovery. + assert not subject_view.get_recovery_in_progress_for_command("c1") + + queue_2 = actions.QueueCommandAction( + "c2", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_2) + run_2 = actions.RunCommandAction(command_id="c2", started_at=datetime.now()) + subject.handle_action(run_2) + fail_2 = actions.FailCommandAction( + command_id="c2", + error_id="c2-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + running_command=subject_view.get("c2"), + ) + subject.handle_action(fail_2) + + # c2 failed recoverably and we're currently recovering from it. + assert subject_view.get_recovery_in_progress_for_command("c2") + # ...and that means we're *not* currently recovering from c1, + # even though it failed recoverably before. + assert not subject_view.get_recovery_in_progress_for_command("c1") + + resume_from_2_recovery = actions.ResumeFromRecoveryAction() + subject.handle_action(resume_from_2_recovery) + queue_3 = actions.QueueCommandAction( + "c3", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_3) + run_3 = actions.RunCommandAction(command_id="c3", started_at=datetime.now()) + subject.handle_action(run_3) + fail_3 = actions.FailCommandAction( + command_id="c3", + error_id="c3-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.FAIL_RUN, + running_command=subject_view.get("c3"), + ) + subject.handle_action(fail_3) + + # c3 failed, but not recoverably. + assert not subject_view.get_recovery_in_progress_for_command("c2") 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 7afde4a6e4b..a859ae7573b 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 @@ -14,12 +14,10 @@ from opentrons.ordered_set import OrderedSet from opentrons.protocol_engine.actions.actions import RunCommandAction -from opentrons.protocol_engine.notes.notes import CommandNote from opentrons.types import MountType, DeckSlotName from opentrons.hardware_control.types import DoorState from opentrons.protocol_engine import commands, errors -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.types import DeckSlotLocation, DeckType, WellLocation from opentrons.protocol_engine.state import Config from opentrons.protocol_engine.state.commands import ( @@ -33,7 +31,6 @@ from opentrons.protocol_engine.actions import ( QueueCommandAction, SucceedCommandAction, - FailCommandAction, PlayAction, PauseAction, PauseSource, @@ -86,6 +83,7 @@ def test_initial_state( finish_error=None, failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, latest_command_hash=None, stopped_by_estop=False, ) @@ -429,321 +427,6 @@ def test_running_command_id() -> None: assert subject.state.command_history.get_running_command() is None -def test_command_failure_clears_queues() -> None: - """It should clear the command queue on command failure.""" - queue_1 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_2 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-2" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - run_1 = RunCommandAction( - command_id="command-id-1", - started_at=datetime(year=2022, month=2, day=2), - ) - fail_1 = FailCommandAction( - command_id="command-id-1", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.FAIL_RUN, - ) - - expected_failed_1 = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - errorType="ProtocolEngineError", - detail="oh no", - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - expected_failed_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_1) - subject.handle_action(queue_2) - subject.handle_action(run_1) - subject.handle_action(fail_1) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_queue_ids() == OrderedSet() - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=expected_failed_1 - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_failed_2 - ) - - -def test_setup_command_failure_only_clears_setup_command_queue() -> None: - """It should clear only the setup command queue for a failed setup command. - - This test queues up a non-setup command followed by two setup commands, - then attempts to run and fail the first setup command and - """ - cmd_1_non_setup = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - createdAt=datetime(year=2021, month=1, day=1), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.QUEUED, - ) - queue_action_1_non_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=cmd_1_non_setup.params, key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_action_2_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), - intent=commands.CommandIntent.SETUP, - key="command-key-2", - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - queue_action_3_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), - intent=commands.CommandIntent.SETUP, - key="command-key-3", - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-3", - ) - - run_action_cmd_2 = RunCommandAction( - command_id="command-id-2", - started_at=datetime(year=2022, month=2, day=2), - ) - failed_action_cmd_2 = FailCommandAction( - command_id="command-id-2", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.FAIL_RUN, - ) - expected_failed_cmd_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorType="ProtocolEngineError", - detail="oh no", - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - intent=commands.CommandIntent.SETUP, - ) - expected_failed_cmd_3 = commands.WaitForResume( - id="command-id-3", - key="command-key-3", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - intent=commands.CommandIntent.SETUP, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_action_1_non_setup) - subject.handle_action(queue_action_2_setup) - subject.handle_action(queue_action_3_setup) - subject.handle_action(run_action_cmd_2) - subject.handle_action(failed_action_cmd_2) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() - assert subject.state.command_history.get_queue_ids() == OrderedSet(["command-id-1"]) - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - "command-id-3", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=cmd_1_non_setup - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_failed_cmd_2 - ) - assert subject.state.command_history.get("command-id-3") == CommandEntry( - index=2, command=expected_failed_cmd_3 - ) - - -def test_nonfatal_command_failure() -> None: - """Test the command queue if a command fails recoverably. - - Commands that were after the failed command in the queue should be left in - the queue. - """ - queue_1 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_2 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-2" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - run_1 = RunCommandAction( - command_id="command-id-1", - started_at=datetime(year=2022, month=2, day=2), - ) - fail_1 = FailCommandAction( - command_id="command-id-1", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.WAIT_FOR_RECOVERY, - ) - - expected_failed_1 = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - errorType="ProtocolEngineError", - detail="oh no", - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - expected_queued_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - startedAt=None, - completedAt=None, - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.QUEUED, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_1) - subject.handle_action(queue_2) - subject.handle_action(run_1) - subject.handle_action(fail_1) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_queue_ids() == OrderedSet(["command-id-2"]) - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=expected_failed_1 - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_queued_2 - ) - - def test_command_store_keeps_commands_in_queue_order() -> None: """It should keep commands in the order they were originally enqueued.""" command_create_1_non_setup = commands.CommentCreate( @@ -834,6 +517,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, latest_command_hash=None, stopped_by_estop=False, ) @@ -859,6 +543,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, run_started_at=datetime(year=2021, month=1, day=1), latest_command_hash=None, stopped_by_estop=False, @@ -890,6 +575,7 @@ def test_command_store_handles_finish_action() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), latest_command_hash=None, stopped_by_estop=False, @@ -936,6 +622,7 @@ def test_command_store_handles_stop_action(from_estop: bool) -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), latest_command_hash=None, stopped_by_estop=from_estop, @@ -966,6 +653,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, run_started_at=None, latest_command_hash=None, stopped_by_estop=False, @@ -1098,6 +786,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, latest_command_hash=None, stopped_by_estop=False, ) @@ -1159,6 +848,7 @@ def __init__(self, message: str) -> None: ), failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=None, latest_command_hash=None, stopped_by_estop=False, @@ -1191,6 +881,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, run_started_at=datetime(year=2021, month=1, day=1), latest_command_hash=None, stopped_by_estop=False, @@ -1223,6 +914,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, run_started_at=datetime(year=2021, month=1, day=1), latest_command_hash=None, stopped_by_estop=False, @@ -1233,102 +925,6 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() -def test_command_store_handles_command_failed() -> None: - """It should store an error and mark the command if it fails.""" - error_recovery_type = ErrorRecoveryType.FAIL_RUN - - expected_error_occurrence = errors.ErrorOccurrence( - id="error-id", - errorType="ProtocolEngineError", - createdAt=datetime(year=2023, month=3, day=3), - detail="oh no", - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - ) - - expected_failed_command = commands.Comment( - id="command-id", - commandType="comment", - key="command-key", - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=expected_error_occurrence.createdAt, - status=commands.CommandStatus.FAILED, - params=commands.CommentParams(message="hello, world"), - result=None, - error=expected_error_occurrence, - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action( - QueueCommandAction( - command_id=expected_failed_command.id, - created_at=expected_failed_command.createdAt, - request=commands.CommentCreate( - params=expected_failed_command.params, key=expected_failed_command.key - ), - request_hash=None, - ) - ) - subject.handle_action( - RunCommandAction( - command_id=expected_failed_command.id, - # Ignore arg-type errors because we know this isn't None. - started_at=expected_failed_command.startedAt, # type: ignore[arg-type] - ) - ) - subject.handle_action( - FailCommandAction( - command_id=expected_failed_command.id, - error_id=expected_error_occurrence.id, - failed_at=expected_error_occurrence.createdAt, - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=error_recovery_type, - ) - ) - - failed_command_entry = CommandEntry(index=0, command=expected_failed_command) - command_history = CommandHistory() - command_history._add("command-id", failed_command_entry) - command_history._set_terminal_command_id("command-id") - - assert subject.state == CommandState( - command_history=command_history, - queue_status=QueueStatus.SETUP, - run_result=None, - run_completed_at=None, - is_door_blocking=False, - run_error=None, - finish_error=None, - failed_command=failed_command_entry, - command_error_recovery_types={expected_failed_command.id: error_recovery_type}, - run_started_at=None, - latest_command_hash=None, - stopped_by_estop=False, - ) - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_all_ids() == ["command-id"] - assert subject.state.command_history.get_queue_ids() == OrderedSet() - assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() - assert subject.state.command_history.get("command-id") == failed_command_entry - - def test_handles_hardware_stopped() -> None: """It should mark the hardware as stopped on HardwareStoppedAction.""" subject = CommandStore(is_door_open=False, config=_make_config()) @@ -1347,6 +943,7 @@ def test_handles_hardware_stopped() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=None, latest_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 64d7670f662..a9b5fc92cc3 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 @@ -58,6 +58,7 @@ def get_command_view( run_error: Optional[errors.ErrorOccurrence] = None, failed_command: Optional[CommandEntry] = None, command_error_recovery_types: Optional[Dict[str, ErrorRecoveryType]] = None, + recovery_target_command_id: Optional[str] = None, finish_error: Optional[errors.ErrorOccurrence] = None, commands: Sequence[cmd.Command] = (), latest_command_hash: Optional[str] = None, @@ -90,6 +91,7 @@ def get_command_view( 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, run_started_at=run_started_at, latest_command_hash=latest_command_hash, stopped_by_estop=False, diff --git a/api/tests/opentrons/protocol_engine/state/test_state_store.py b/api/tests/opentrons/protocol_engine/state/test_state_store.py index dd32bbec591..170f05bb4b9 100644 --- a/api/tests/opentrons/protocol_engine/state/test_state_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_state_store.py @@ -1,5 +1,5 @@ """Tests for the top-level StateStore/StateView.""" -from typing import Callable, Optional +from typing import Callable, Union from datetime import datetime import pytest @@ -80,47 +80,52 @@ def test_notify_on_state_change( decoy.verify(change_notifier.notify(), times=1) -async def test_wait_for_state( +async def test_wait_for( decoy: Decoy, change_notifier: ChangeNotifier, subject: StateStore, ) -> None: """It should return an awaitable that signals state changes.""" - check_condition: Callable[..., Optional[str]] = decoy.mock(name="check_condition") + check_condition: Callable[..., Union[str, int]] = decoy.mock(name="check_condition") decoy.when(check_condition("foo", bar="baz")).then_return( - None, - None, + 0, + 0, "hello world", ) - result = await subject.wait_for(check_condition, "foo", bar="baz") assert result == "hello world" + decoy.verify(await change_notifier.wait(), times=2) + decoy.reset() + + decoy.when(check_condition("foo", bar="baz")).then_return( + "hello world", + "hello world again", + 0, + ) + result = await subject.wait_for_not(check_condition, "foo", bar="baz") + assert result == 0 decoy.verify(await change_notifier.wait(), times=2) -async def test_wait_for_state_short_circuit( +async def test_wait_for_already_satisfied( decoy: Decoy, subject: StateStore, change_notifier: ChangeNotifier, ) -> None: - """It should short-circuit the change notifier if condition is satisfied.""" - check_condition: Callable[..., Optional[str]] = decoy.mock(name="check_condition") + """It should return immediately and skip the change notifier.""" + check_condition: Callable[..., Union[str, int]] = decoy.mock(name="check_condition") decoy.when(check_condition("foo", bar="baz")).then_return("hello world") - result = await subject.wait_for(check_condition, "foo", bar="baz") assert result == "hello world" - decoy.verify(await change_notifier.wait(), times=0) - -async def test_wait_for_already_true(decoy: Decoy, subject: StateStore) -> None: - """It should signal immediately if condition is already met.""" - check_condition = decoy.mock(name="check_condition") - decoy.when(check_condition()).then_return(True) - await subject.wait_for(check_condition) + decoy.when(check_condition("foo", bar="baz")).then_return(0) + result = await subject.wait_for_not(check_condition, "foo", bar="baz") + assert result == 0 + decoy.verify(await change_notifier.wait(), times=0) async def test_wait_for_raises(decoy: Decoy, subject: StateStore) -> None: @@ -131,3 +136,6 @@ async def test_wait_for_raises(decoy: Decoy, subject: StateStore) -> None: with pytest.raises(ValueError, match="oh no"): await subject.wait_for(check_condition) + + with pytest.raises(ValueError, match="oh no"): + await subject.wait_for_not(check_condition) diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 2191b1c4954..dd96b8d968a 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -2,6 +2,7 @@ import inspect from datetime import datetime from typing import Any +from unittest.mock import sentinel import pytest from decoy import Decoy @@ -333,6 +334,99 @@ def _stub_completed(*_a: object, **_k: object) -> bool: assert result == completed +async def test_add_and_execute_command_wait_for_recovery( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should add and execute a command from a request.""" + created_at = datetime(year=2021, month=1, day=1) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate(params=commands.HomeParams()) + queued = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.QUEUED, + createdAt=created_at, + params=commands.HomeParams(), + ) + completed = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.SUCCEEDED, + createdAt=created_at, + params=commands.HomeParams(), + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + decoy.when(model_utils.generate_id()).then_return("command-id") + decoy.when(model_utils.get_timestamp()).then_return(created_at) + + def _stub_queued(*_a: object, **_k: object) -> None: + decoy.when(state_store.commands.get("command-id")).then_return(queued) + + def _stub_completed(*_a: object, **_k: object) -> bool: + decoy.when(state_store.commands.get("command-id")).then_return(completed) + return True + + decoy.when( + state_store.commands.validate_action_allowed( + QueueCommandAction( + command_id="command-id", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_return( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + + decoy.when( + action_dispatcher.dispatch( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_do(_stub_queued) + + decoy.when( + await state_store.wait_for( + condition=state_store.commands.get_command_is_final, + command_id="command-id", + ), + ).then_do(_stub_completed) + + result = await subject.add_and_execute_command_wait_for_recovery(original_request) + assert result == completed + decoy.verify( + await state_store.wait_for_not( + state_store.commands.get_recovery_in_progress_for_command, + "command-id", + ) + ) + + def test_play( decoy: Decoy, state_store: StateStore, @@ -764,6 +858,8 @@ async def test_estop_during_command( """It should be able to stop the engine.""" timestamp = datetime(2021, 1, 1, 0, 0) command_id = "command_fake_id" + running_command = sentinel.running_command + queued_command = sentinel.queued_command error_id = "fake_error_id" fake_command_set = OrderedSet(["fake-id-1", "fake-id-1"]) @@ -771,10 +867,15 @@ async def test_estop_during_command( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(state_store.commands.get_is_stopped()).then_return(False) decoy.when(state_store.commands.get_running_command_id()).then_return(command_id) + decoy.when(state_store.commands.get(command_id)).then_return(running_command) decoy.when(state_store.commands.get_queue_ids()).then_return(fake_command_set) + decoy.when(state_store.commands.get(fake_command_set.head())).then_return( + queued_command + ) expected_action = FailCommandAction( command_id=command_id, + running_command=running_command, error_id=error_id, failed_at=timestamp, error=EStopActivatedError(message="Estop Activated"), @@ -783,6 +884,7 @@ async def test_estop_during_command( ) expected_action_2 = FailCommandAction( command_id=fake_command_set.head(), + running_command=queued_command, error_id=error_id, failed_at=timestamp, error=EStopActivatedError(message="Estop Activated"), 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 23b7ecac3bb..f0412878856 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py @@ -156,6 +156,7 @@ def test_map_after_with_error_command() -> None: assert result == [ pe_actions.FailCommandAction( command_id="command.COMMENT-0", + running_command=matchers.Anything(), error_id=matchers.IsA(str), failed_at=matchers.IsA(datetime), error=matchers.ErrorMatching( @@ -257,6 +258,7 @@ def test_command_stack() -> None: ), pe_actions.FailCommandAction( command_id="command.COMMENT-1", + running_command=matchers.Anything(), error_id=matchers.IsA(str), failed_at=matchers.IsA(datetime), error=matchers.ErrorMatching(LegacyContextCommandError, "oh no"), From 2cff9d24c542185c8d7de5efbfa1c137b64ac210 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Tue, 9 Apr 2024 12:48:20 -0400 Subject: [PATCH 19/49] feat(hardware-testing): liquid sense testing script (#14807) # Overview This PR adds a new testing script that allows us to test all kinds of variations of the liquid-sense routine it adds some additional features in the hardware control layer to change up output options to during the probe so we can gate using the buffer-on-pipette feature to a firmware version flag, since that feature has to be compiled in separately # Test Plan # Changelog # Review requests # Risk assessment --------- Co-authored-by: caila-marashaj --- hardware-testing/Makefile | 8 + .../gravimetric/measurement/record.py | 13 +- .../labware/dial_indicator/1.json | 57 ++++ .../hardware_testing/liquid_sense/__init__.py | 1 + .../hardware_testing/liquid_sense/__main__.py | 317 ++++++++++++++++++ .../hardware_testing/liquid_sense/execute.py | 307 +++++++++++++++++ .../liquid_sense/post_process.py | 170 ++++++++++ .../hardware_testing/liquid_sense/report.py | 263 +++++++++++++++ .../opentrons_api/helpers_ot3.py | 4 +- .../protocols/liquid_sense_lpc/__init__.py | 1 + .../liquid_sense_ot3_p1000_96.py | 33 ++ .../liquid_sense_ot3_p1000_multi.py | 26 ++ .../liquid_sense_ot3_p1000_single.py | 33 ++ .../liquid_sense_ot3_p50_multi.py | 28 ++ .../liquid_sense_ot3_p50_single.py | 31 ++ .../firmware_bindings/messages/messages.py | 1 + .../hardware_control/tool_sensors.py | 3 +- 17 files changed, 1287 insertions(+), 9 deletions(-) create mode 100644 hardware-testing/hardware_testing/labware/dial_indicator/1.json create mode 100644 hardware-testing/hardware_testing/liquid_sense/__init__.py create mode 100644 hardware-testing/hardware_testing/liquid_sense/__main__.py create mode 100644 hardware-testing/hardware_testing/liquid_sense/execute.py create mode 100644 hardware-testing/hardware_testing/liquid_sense/post_process.py create mode 100644 hardware-testing/hardware_testing/liquid_sense/report.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py create mode 100644 hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index 6c12dc305a0..a48b794977f 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -155,6 +155,14 @@ test-examples: test-scripts: $(python) -m hardware_testing.scripts.bowtie_ot3 --simulate +.PHONY: test-liquid-sense +test-liquid-sense: + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 1 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 50 --channels 1 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 8 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 50 --channels 8 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 96 + .PHONY: test-integration test-integration: test-production-qc test-examples test-scripts test-gravimetric diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/record.py b/hardware-testing/hardware_testing/gravimetric/measurement/record.py index d1e4ab7e4d4..86ef8b84903 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/record.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/record.py @@ -280,7 +280,11 @@ class GravimetricRecorder: """Gravimetric Recorder.""" def __init__( - self, cfg: GravimetricRecorderConfig, scale: Scale, simulate: bool = False + self, + cfg: GravimetricRecorderConfig, + scale: Scale, + simulate: bool = False, + start_graph: bool = True, ) -> None: """Gravimetric Recorder.""" self._cfg = cfg @@ -294,7 +298,7 @@ def __init__( self._scale_serial: str = "" self._scale_max_capacity: float = 0.0 super().__init__() - self.activate() + self.activate(start_graph) def _start_graph_server_process(self) -> None: if self.is_simulator: @@ -350,9 +354,10 @@ def add_simulation_mass(self, mass: float) -> None: """Add simulation mass.""" self._scale.add_simulation_mass(mass) - def activate(self) -> None: + def activate(self, graph: bool = True) -> None: """Activate.""" - self._start_graph_server_process() + if graph: + self._start_graph_server_process() # Some Radwag settings cannot be controlled remotely. # Listed below are the things the must be done using the touchscreen: # 1) Set profile to USER diff --git a/hardware-testing/hardware_testing/labware/dial_indicator/1.json b/hardware-testing/hardware_testing/labware/dial_indicator/1.json new file mode 100644 index 00000000000..6c3ac9c3f24 --- /dev/null +++ b/hardware-testing/hardware_testing/labware/dial_indicator/1.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 2, + "version": 1, + "namespace": "custom_beta", + "ordering": [["A1"]], + "metadata": { + "displayName": "Mitutoyo Digimatic Indicator", + "displayCategory": "tubeRack", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 128, + "yDimension": 86, + "zDimension": 136 + }, + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "dial_indicator" + }, + "wells": { + "A1": { + "depth": 14, + "totalLiquidVolume": 10, + "shape": "circular", + "diameter": 4, + "x": 60.8, + "y": 41.5, + "z": 135 + } + }, + "brand": { + "brand": "Mitutoyo", + "brandId": ["ID-S"] + }, + "groups": [ + { + "brand": { + "brand": "Mitutoyo", + "brandId": ["ID-S"] + }, + "metadata": { + "wellBottomShape": "flat", + "displayCategory": "tubeRack" + }, + "wells": ["A1"] + } + ], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/hardware-testing/hardware_testing/liquid_sense/__init__.py b/hardware-testing/hardware_testing/liquid_sense/__init__.py new file mode 100644 index 00000000000..e6b26332d7b --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/__init__.py @@ -0,0 +1 @@ +"""Liquid Sense.""" diff --git a/hardware-testing/hardware_testing/liquid_sense/__main__.py b/hardware-testing/hardware_testing/liquid_sense/__main__.py new file mode 100644 index 00000000000..10db70e67c8 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/__main__.py @@ -0,0 +1,317 @@ +"""Liquid sense testing.""" +import argparse +from dataclasses import dataclass +from json import load as json_load +from pathlib import Path +import subprocess +from time import sleep +import os +from typing import List, Any, Optional +import traceback + +from hardware_testing.opentrons_api import helpers_ot3 +from hardware_testing.gravimetric import helpers, workarounds +from hardware_testing.data.csv_report import CSVReport +from hardware_testing.gravimetric.measurement.record import GravimetricRecorder +from hardware_testing.gravimetric.measurement.scale import Scale +from hardware_testing.drivers import ( + asair_sensor, + mitutoyo_digimatic_indicator, + list_ports_and_select, +) +from hardware_testing.data import ( + ui, + create_run_id_and_start_time, + get_git_description, + get_testing_data_directory, +) + +from opentrons.protocol_api import InstrumentContext, ProtocolContext +from opentrons.protocol_engine.types import LabwareOffset + +from hardware_testing.liquid_sense import execute +from .report import build_ls_report, store_config, store_serial_numbers +from .post_process import process_csv_directory + +from hardware_testing.protocols.liquid_sense_lpc import ( + liquid_sense_ot3_p50_single, + liquid_sense_ot3_p50_multi, + liquid_sense_ot3_p1000_single, + liquid_sense_ot3_p1000_multi, + liquid_sense_ot3_p1000_96, +) + +API_LEVEL = "2.18" + +LABWARE_OFFSETS: List[LabwareOffset] = [] + + +LIQUID_SENSE_CFG = { + 50: { + 1: liquid_sense_ot3_p50_single, + 8: liquid_sense_ot3_p50_multi, + }, + 1000: { + 1: liquid_sense_ot3_p1000_single, + 8: liquid_sense_ot3_p1000_multi, + 96: liquid_sense_ot3_p1000_96, + }, +} + +PIPETTE_MODEL_NAME = { + 50: { + 1: "p50_single_flex", + 8: "p50_multi_flex", + }, + 1000: { + 1: "p1000_single_flex", + 8: "p1000_multi_flex", + 96: "p1000_96_flex", + }, +} + + +@dataclass +class RunArgs: + """Common resources across multiple runs.""" + + tip_volumes: List[int] + run_id: str + pipette: InstrumentContext + pipette_tag: str + git_description: str + robot_serial: str + recorder: GravimetricRecorder + pipette_volume: int + pipette_channels: int + name: str + environment_sensor: asair_sensor.AsairSensorBase + trials: int + z_speed: float + return_tip: bool + ctx: ProtocolContext + protocol_cfg: Any + test_report: CSVReport + start_height_offset: float + aspirate: bool + dial_indicator: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] + plunger_speed: bool + trials_before_jog: int + + @classmethod + def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: + if not args.simulate and not args.skip_labware_offsets: + # getting labware offsets must be done before creating the protocol context + # because it requires the robot-server to be running + ui.print_title("SETUP") + ui.print_info( + "Starting opentrons-robot-server, so we can http GET labware offsets" + ) + LABWARE_OFFSETS.extend(workarounds.http_get_all_labware_offsets()) + ui.print_info(f"found {len(LABWARE_OFFSETS)} offsets:") + for offset in LABWARE_OFFSETS: + ui.print_info(f"\t{offset.createdAt}:") + ui.print_info(f"\t\t{offset.definitionUri}") + ui.print_info(f"\t\t{offset.vector}") + # gather the custom labware (for simulation) + custom_defs = {} + if args.simulate: + labware_dir = Path(__file__).parent.parent / "labware" + custom_def_uris = [ + "radwag_pipette_calibration_vial", + "dial_indicator", + ] + for def_uri in custom_def_uris: + with open(labware_dir / def_uri / "1.json", "r") as f: + custom_def = json_load(f) + custom_defs[def_uri] = custom_def + _ctx = helpers.get_api_context( + API_LEVEL, # type: ignore[attr-defined] + is_simulating=args.simulate, + pipette_left=PIPETTE_MODEL_NAME[args.pipette][args.channels], + extra_labware=custom_defs, + ) + for offset in LABWARE_OFFSETS: + engine = _ctx._core._engine_client._transport._engine # type: ignore[attr-defined] + engine.state_view._labware_store._add_labware_offset(offset) + return _ctx + + @classmethod + def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": + """Build.""" + _ctx = RunArgs._get_protocol_context(args) + robot_serial = helpers._get_robot_serial(_ctx.is_simulating()) + run_id, start_time = create_run_id_and_start_time() + environment_sensor = asair_sensor.BuildAsairSensor( + _ctx.is_simulating() or args.ignore_env + ) + git_description = get_git_description() + protocol_cfg = LIQUID_SENSE_CFG[args.pipette][args.channels] + name = protocol_cfg.metadata["protocolName"] # type: ignore[attr-defined] + ui.print_header("LOAD PIPETTE") + pipette = _ctx.load_instrument( + f"flex_{args.channels}channel_{args.pipette}", "left" + ) + loaded_labwares = _ctx.loaded_labwares + if 12 in loaded_labwares.keys(): + trash = loaded_labwares[12] + else: + trash = _ctx.load_labware("opentrons_1_trash_3200ml_fixed", "A3") + pipette.trash_container = trash + pipette_tag = helpers._get_tag_from_pipette(pipette, False, False) + + if args.trials == 0: + trials = 10 + else: + trials = args.trials + + if args.tip == 0: + if args.pipette == 1000: + tip_volumes: List[int] = [50, 200, 1000] + else: + tip_volumes = [50] + else: + tip_volumes = [args.tip] + + scale = Scale.build(simulate=_ctx.is_simulating() or args.ignore_scale) + recorder: GravimetricRecorder = execute._load_scale( + name, + scale, + run_id, + pipette_tag, + start_time, + _ctx.is_simulating() or args.ignore_scale, + ) + dial: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] = None + if not _ctx.is_simulating() and not args.ignore_dial: + dial_port = list_ports_and_select("Dial Indicator") + dial = mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator( + port=dial_port + ) + dial.connect() + ui.print_info(f"pipette_tag {pipette_tag}") + report = build_ls_report(name, run_id, trials, tip_volumes) + report.set_tag(name) + # go ahead and store the meta data now + store_serial_numbers( + report, + robot_serial, + pipette_tag, + scale.read_serial_number(), + environment_sensor.get_serial(), + git_description, + ) + + store_config( + report, + name, + args.pipette, + tip_volumes, + trials, + args.plunger_direction, + args.liquid, + protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] + args.z_speed, + args.start_height_offset, + ) + return RunArgs( + tip_volumes=tip_volumes, + run_id=run_id, + pipette=pipette, + pipette_tag=pipette_tag, + git_description=git_description, + robot_serial=robot_serial, + recorder=recorder, + pipette_volume=args.pipette, + pipette_channels=args.channels, + name=name, + environment_sensor=environment_sensor, + trials=trials, + z_speed=args.z_speed, + return_tip=args.return_tip, + ctx=_ctx, + protocol_cfg=protocol_cfg, + test_report=report, + start_height_offset=args.start_height_offset, + aspirate=args.plunger_direction == "aspirate", + dial_indicator=dial, + plunger_speed=args.plunger_speed, + trials_before_jog=args.trials_before_jog, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser("Pipette Testing") + parser.add_argument("--simulate", action="store_true") + parser.add_argument("--pipette", type=int, choices=[50, 1000], required=True) + parser.add_argument("--channels", type=int, choices=[1, 8, 96], default=1) + parser.add_argument("--tip", type=int, choices=[0, 50, 200, 1000], default=0) + parser.add_argument("--trials", type=int, default=0) + parser.add_argument("--return-tip", action="store_true") + parser.add_argument("--skip-labware-offsets", action="store_true") + parser.add_argument( + "--liquid", type=str, choices=["water", "glycerol", "alchohol"], default="water" + ) + parser.add_argument("--z-speed", type=float, default=5) + parser.add_argument( + "--plunger-direction", + type=str, + choices=["aspirate", "dispense"], + default="aspirate", + ) + parser.add_argument("--labware-type", type=str, default="nest_1_reservoir_195ml") + parser.add_argument("--plunger-speed", type=float, default=-1.0) + parser.add_argument("--isolate-plungers", action="store_true") + parser.add_argument("--start-height-offset", type=float, default=0) + parser.add_argument("--ignore-scale", action="store_true") + parser.add_argument("--ignore-env", action="store_true") + parser.add_argument("--ignore-dial", action="store_true") + parser.add_argument("--trials-before-jog", type=int, default=10) + + args = parser.parse_args() + run_args = RunArgs.build_run_args(args) + try: + if not run_args.ctx.is_simulating(): + data_dir = get_testing_data_directory() + data_file = f"/{data_dir}/{run_args.name}/{run_args.run_id}/serial.log" + ui.print_info(f"logging can data to {data_file}") + serial_logger = subprocess.Popen( + [f"python3 -m opentrons_hardware.scripts.can_mon > {data_file}"], + shell=True, + ) + sleep(1) + hw = run_args.ctx._core.get_hardware() + if not run_args.ctx.is_simulating(): + ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") + ui.print_info("homing...") + run_args.ctx.home() + for tip in run_args.tip_volumes: + if args.channels == 96 and not run_args.ctx.is_simulating(): + ui.alert_user_ready(f"prepare the {tip}ul tipracks", hw) + execute.run(tip, run_args) + except Exception as e: + ui.print_info(f"got error {e}") + ui.print_info(traceback.format_exc()) + finally: + if run_args.recorder is not None: + ui.print_info("ending recording") + run_args.recorder.stop() + run_args.recorder.deactivate() + if not run_args.ctx.is_simulating(): + ui.print_info("killing serial log") + serial_logger.terminate() + if run_args.dial_indicator is not None: + run_args.dial_indicator.disconnect() + run_args.test_report.save_to_disk() + run_args.test_report.print_results() + ui.print_info("done\n\n") + if not run_args.ctx.is_simulating(): + process_csv_directory( + f"{data_dir}/{run_args.name}/{run_args.run_id}", + run_args.tip_volumes, + run_args.trials, + ) + run_args.ctx.cleanup() + if not args.simulate: + helpers_ot3.restart_server_ot3() + os._exit(os.EX_OK) diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py new file mode 100644 index 00000000000..1fc95d62d44 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -0,0 +1,307 @@ +"""Logic for running a single liquid probe test.""" +from typing import Dict, Any, List, Tuple, Optional +from .report import store_tip_results, store_trial, store_baseline_trial +from opentrons.config.types import LiquidProbeSettings, OutputOptions +from .__main__ import RunArgs +from hardware_testing.gravimetric.workarounds import get_sync_hw_api +from hardware_testing.gravimetric.helpers import ( + _jog_to_find_liquid_height, +) +from hardware_testing.gravimetric.config import LIQUID_PROBE_SETTINGS +from hardware_testing.gravimetric.tips import get_unused_tips +from hardware_testing.data import ui, get_testing_data_directory +from opentrons.hardware_control.types import ( + InstrumentProbeType, + OT3Mount, + Axis, + top_types, +) + +from hardware_testing.gravimetric.measurement.scale import Scale +from hardware_testing.gravimetric.measurement.record import ( + GravimetricRecorder, + GravimetricRecorderConfig, +) +from opentrons.protocol_api._types import OffDeckType + +from opentrons.protocol_api import ProtocolContext, Well, Labware + + +def _load_tipracks( + ctx: ProtocolContext, pipette_channels: int, protocol_cfg: Any, tip: int +) -> List[Labware]: + # TODO add logic here for partial tip using 96 + use_adapters: bool = pipette_channels == 96 + tiprack_load_settings: List[Tuple[int, str]] = [ + ( + slot, + f"opentrons_flex_96_tiprack_{tip}ul", + ) + for slot in protocol_cfg.SLOTS_TIPRACK[tip] # type: ignore[attr-defined] + ] + for ls in tiprack_load_settings: + ui.print_info(f'Loading tiprack "{ls[1]}" in slot #{ls[0]}') + + adapter: Optional[str] = ( + "opentrons_flex_96_tiprack_adapter" if use_adapters else None + ) + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = ctx.loaded_labwares + ui.print_info(f"Loaded labwares {loaded_labwares}") + pre_loaded_tips: List[Labware] = [] + for ls in tiprack_load_settings: + if ls[0] in loaded_labwares.keys(): + if loaded_labwares[ls[0]].name == ls[1]: + pre_loaded_tips.append(loaded_labwares[ls[0]]) + else: + # If something is in the slot that's not what we want, remove it + # we use this only for the 96 channel + ui.print_info( + f"Removing {loaded_labwares[ls[0]].name} from slot {ls[0]}" + ) + ctx._core.move_labware( + loaded_labwares[ls[0]]._core, + new_location=OffDeckType.OFF_DECK, + use_gripper=False, + pause_for_manual_move=False, + pick_up_offset=None, + drop_offset=None, + ) + if len(pre_loaded_tips) == len(tiprack_load_settings): + return pre_loaded_tips + + tipracks: List[Labware] = [] + for ls in tiprack_load_settings: + if ctx.deck[ls[0]] is not None: + tipracks.append( + ctx.deck[ls[0]].load_labware(ls[1]) # type: ignore[union-attr] + ) + else: + tipracks.append(ctx.load_labware(ls[1], location=ls[0], adapter=adapter)) + return tipracks + + +def _load_dial_indicator(run_args: RunArgs) -> Labware: + slot_dial = run_args.protocol_cfg.SLOT_DIAL # type: ignore[union-attr] + dial_labware_name = "dial_indicator" + loaded_labwares = run_args.ctx.loaded_labwares + if ( + slot_dial in loaded_labwares.keys() + and loaded_labwares[slot_dial].name == dial_labware_name + ): + return loaded_labwares[slot_dial] + + dial_labware = run_args.ctx.load_labware( + dial_labware_name, location=slot_dial, namespace="custom_beta" + ) + return dial_labware + + +def _load_test_well(run_args: RunArgs) -> Labware: + slot_scale = run_args.protocol_cfg.SLOT_SCALE # type: ignore[union-attr] + labware_on_scale = run_args.protocol_cfg.LABWARE_ON_SCALE # type: ignore[union-attr] + ui.print_info(f'Loading labware on scale: "{labware_on_scale}"') + if labware_on_scale == "radwag_pipette_calibration_vial": + namespace = "custom_beta" + else: + namespace = "opentrons" + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = run_args.ctx.loaded_labwares + if ( + slot_scale in loaded_labwares.keys() + and loaded_labwares[slot_scale].name == labware_on_scale + ): + return loaded_labwares[slot_scale] + + labware_on_scale = run_args.ctx.load_labware( + labware_on_scale, location=slot_scale, namespace=namespace + ) + return labware_on_scale + + +def _load_scale( + name: str, + scale: Scale, + run_id: str, + pipette_tag: str, + start_time: float, + simulating: bool, +) -> GravimetricRecorder: + ui.print_header("LOAD SCALE") + ui.print_info( + "Some Radwag settings cannot be controlled remotely.\n" + "Listed below are the things the must be done using the touchscreen:\n" + " 1) Set profile to USER\n" + " 2) Set screensaver to NONE\n" + ) + recorder = GravimetricRecorder( + GravimetricRecorderConfig( + test_name=name, + run_id=run_id, + tag=pipette_tag, + start_time=start_time, + duration=0, + frequency=1000 if simulating else 60, + stable=False, + ), + scale, + simulate=simulating, + start_graph=False, + ) + ui.print_info(f'found scale "{recorder.serial_number}"') + if simulating: + recorder.set_simulation_mass(0) + recorder.record(in_thread=True) + ui.print_info(f'scale is recording to "{recorder.file_name}"') + return recorder + + +def run(tip: int, run_args: RunArgs) -> None: + """Run a liquid probe test.""" + test_labware: Labware = _load_test_well(run_args) + dial_indicator: Labware = _load_dial_indicator(run_args) + dial_well: Well = dial_indicator["A1"] + hw_api = get_sync_hw_api(run_args.ctx) + test_well: Well = test_labware["A1"] + _load_tipracks(run_args.ctx, run_args.pipette_channels, run_args.protocol_cfg, tip) + tips: List[Well] = get_unused_tips( + ctx=run_args.ctx, tip_volume=tip, pipette_mount="" + ) + assert len(tips) >= run_args.trials + results: List[float] = [] + adjusted_results: List[float] = [] + lpc_offset = 0.0 + if run_args.dial_indicator is not None: + run_args.pipette.move_to(dial_well.top()) + lpc_offset = run_args.dial_indicator.read_stable() + run_args.pipette._retract() + + def _get_baseline() -> float: + run_args.pipette.pick_up_tip(tips.pop(0)) + liquid_height = _jog_to_find_liquid_height( + run_args.ctx, run_args.pipette, test_well + ) + target_height = test_well.bottom(liquid_height).point.z + + run_args.pipette._retract() + # tip_offset = 0.0 + if run_args.dial_indicator is not None: + run_args.pipette.move_to(dial_well.top()) + tip_offset = run_args.dial_indicator.read_stable() + run_args.pipette._retract() + if run_args.return_tip: + run_args.pipette.return_tip() + else: + run_args.pipette.drop_tip() + + env_data = run_args.environment_sensor.get_reading() + + store_baseline_trial( + run_args.test_report, + tip, + target_height, + env_data.relative_humidity, + env_data.temperature, + test_well.top().point.z - target_height, + tip_offset - lpc_offset, + ) + return target_height + + trials_before_jog = run_args.trials_before_jog + tip_offset = 0.0 + for trial in range(run_args.trials): + if trial % trials_before_jog == 0: + tip_offset = _get_baseline() + + ui.print_info(f"Picking up {tip}ul tip") + run_args.pipette.pick_up_tip(tips.pop(0)) + run_args.pipette.move_to(test_well.top()) + + start_pos = hw_api.current_position_ot3(OT3Mount.LEFT) + height = _run_trial(run_args, tip, test_well, trial) + end_pos = hw_api.current_position_ot3(OT3Mount.LEFT) + run_args.pipette.blow_out() + tip_length_offset = 0.0 + if run_args.dial_indicator is not None: + + run_args.pipette._retract() + run_args.pipette.move_to(dial_well.top()) + tip_length_offset = tip_offset - run_args.dial_indicator.read_stable() + run_args.pipette._retract() + ui.print_info(f"Tip Offset {tip_length_offset}") + + ui.print_info("Droping tip") + if run_args.return_tip: + run_args.pipette.return_tip() + else: + run_args.pipette.drop_tip() + results.append(height) + adjusted_results.append(height + tip_length_offset) + env_data = run_args.environment_sensor.get_reading() + hw_pipette = hw_api.hardware_pipettes[top_types.Mount.LEFT] + plunger_start = ( + hw_pipette.plunger_positions.bottom + if run_args.aspirate + else hw_pipette.plunger_positions.top + ) + store_trial( + run_args.test_report, + trial, + tip, + height, + end_pos[Axis.P_L], + env_data.relative_humidity, + env_data.temperature, + start_pos[Axis.Z_L] - end_pos[Axis.Z_L], + plunger_start - end_pos[Axis.P_L], + tip_length_offset, + ) + ui.print_info( + f"\n\n Z axis start pos {start_pos[Axis.Z_L]} end pos {end_pos[Axis.Z_L]}" + ) + ui.print_info( + f"plunger start pos {plunger_start} end pos {end_pos[Axis.P_L]}\n\n" + ) + + ui.print_info(f"RESULTS: \n{results}") + ui.print_info(f"Adjusted RESULTS: \n{adjusted_results}") + store_tip_results(run_args.test_report, tip, results, adjusted_results) + + +def _run_trial(run_args: RunArgs, tip: int, well: Well, trial: int) -> float: + hw_api = get_sync_hw_api(run_args.ctx) + lqid_cfg: Dict[str, int] = LIQUID_PROBE_SETTINGS[run_args.pipette_volume][ + run_args.pipette_channels + ][tip] + data_dir = get_testing_data_directory() + data_filename = f"pressure_sensor_data-trial{trial}-tip{tip}.csv" + data_file = f"{data_dir}/{run_args.name}/{run_args.run_id}/{data_filename}" + ui.print_info(f"logging pressure data to {data_file}") + + plunger_speed = ( + lqid_cfg["plunger_speed"] + if run_args.plunger_speed == -1 + else run_args.plunger_speed + ) + lps = LiquidProbeSettings( + starting_mount_height=well.top().point.z + run_args.start_height_offset, + max_z_distance=min(well.depth, lqid_cfg["max_z_distance"]), + min_z_distance=lqid_cfg["min_z_distance"], + mount_speed=run_args.z_speed, + plunger_speed=plunger_speed, + sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], + expected_liquid_height=110, + output_option=OutputOptions.sync_buffer_to_csv, + aspirate_while_sensing=run_args.aspirate, + auto_zero_sensor=True, + num_baseline_reads=10, + data_file=data_file, + ) + + hw_mount = OT3Mount.LEFT if run_args.pipette.mount == "left" else OT3Mount.RIGHT + run_args.recorder.set_sample_tag(f"trial-{trial}-{tip}ul") + # TODO add in stuff for secondary probe + height = hw_api.liquid_probe(hw_mount, lps, InstrumentProbeType.PRIMARY) + ui.print_info(f"Trial {trial} complete") + run_args.recorder.clear_sample_tag() + return height diff --git a/hardware-testing/hardware_testing/liquid_sense/post_process.py b/hardware-testing/hardware_testing/liquid_sense/post_process.py new file mode 100644 index 00000000000..20e46ed746a --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/post_process.py @@ -0,0 +1,170 @@ +"""Post process script csvs.""" +import csv +import os +from typing import List, Dict, Tuple +from math import isclose + +COL_TRIAL_CONVERSION = { + 1: "E", + 2: "H", + 3: "K", + 4: "N", + 5: "Q", + 6: "T", + 7: "W", + 8: "Z", + 9: "AC", + 10: "AF", + 11: "AI", + 12: "AL", + 13: "AO", +} + + +def process_csv_directory( # noqa: C901 + data_directory: str, tips: List[int], trials: int, make_graph: bool = False +) -> None: + """Post process script csvs.""" + csv_files: List[str] = os.listdir(data_directory) + summary: str = [f for f in csv_files if "CSVReport" in f][0] + final_report_file: str = f"{data_directory}/final_report.csv" + # initialize our data structs + pressure_csvs = [f for f in csv_files if "pressure_sensor_data" in f] + pressure_results_files: Dict[int, List[str]] = {} + pressure_results: Dict[int, Dict[int, List[float]]] = {} + results_settings: Dict[int, Dict[int, Tuple[float, float, float]]] = {} + tip_offsets: Dict[int, List[float]] = {} + p_offsets: Dict[int, List[float]] = {} + meniscus_travel: float = 0 + for tip in tips: + pressure_results_files[tip] = [f for f in pressure_csvs if f"tip{tip}" in f] + pressure_results[tip] = {} + results_settings[tip] = {} + tip_offsets[tip] = [] + p_offsets[tip] = [i * 0 for i in range(trials)] + for trial in range(trials): + pressure_results[tip][trial] = [] + results_settings[tip][trial] = (0.0, 0.0, 0.0) + max_results_len = 0 + + # read in all of the pressure csvs into one big struct so we can process them + for tip in tips: + for trial in range(trials): + with open( + f"{data_directory}/{pressure_results_files[tip][trial]}", newline="" + ) as trial_csv: + trial_reader = csv.reader(trial_csv) + i = 0 + for row in trial_reader: + if i == 1: + results_settings[tip][trial] = ( + float(row[2]), + float(row[3]), + float(row[4]), + ) + if i > 1: + pressure_results[tip][trial].append(float(row[1])) + i += 1 + max_results_len = max([i - 2, max_results_len]) + # start writing the final report csv + with open(f"{data_directory}/{summary}", newline="") as summary_csv: + summary_reader = csv.reader(summary_csv) + with open(final_report_file, "w", newline="") as final_report: + # copy over the results summary + final_report_writer = csv.writer(final_report) + s = 0 + for row in summary_reader: + final_report_writer.writerow(row) + s += 1 + if s == 45: + meniscus_travel = float(row[6]) + if s >= 46 and s < 46 + (trials * len(tips)): + # while processing this grab the tip offsets from the summary + tip_offsets[tips[int((s - 46) / trials)]].append(float(row[8])) + # summary_reader.line_num is the last line in the summary that has text + pressures_start_line = summary_reader.line_num + 3 + # calculate where the start and end of each block of data we want to graph + final_report_writer.writerow( + [ + "50ul", + f"A{pressures_start_line-1}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line + max_results_len -1}", + "200ul", + f"A{pressures_start_line+max_results_len-1}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line +(2*max_results_len)-1}", + "10000ul", + f"A{pressures_start_line+(2*max_results_len-1)}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line + (3*max_results_len)-1}", + ] + ) + + # build a header row + pressure_header_row = ["time", ""] + for i in range(trials): + pressure_header_row.extend( + [f"pressure T{i+1}", f"z_travel T{i+1}", f"p_travel T{i+1}"] + ) + + # we want to line up the z height's of each trial at time==0 + # to do this we drop the results at the beginning of each of the trials + # except for one with the longest tip (lower tip offset are longer tips) + min_tip_offset = 0.0 + if make_graph: + for tip in tips: + min_tip_offset = min(tip_offsets[tip]) + for trial in range(trials): + for i in range(max_results_len): + if tip_offsets[tip][trial] > min_tip_offset: + # drop this pressure result + pressure_results[tip][trial].pop(0) + # we don't want to change the length of this array so just + # stretch out the last value + pressure_results[tip][trial].append( + pressure_results[tip][trial][-1] + ) + # decrement the offset while this is true + # so we can account for it later + tip_offsets[tip][trial] -= ( + 0.001 * results_settings[tip][0][0] + ) + # keep track of how this effects the plunger start position + p_offsets[tip][trial] = ( + (i + 1) * 0.001 * results_settings[tip][0][1] * -1 + ) + else: + # we've lined up this trial so move to the next + break + # write the processed test data + for tip in tips: + time = 0.0 + final_report_writer.writerow(pressure_header_row) + meniscus_time = (meniscus_travel + min_tip_offset) / results_settings[ + tip + ][0][0] + for i in range(max_results_len): + pressure_row: List[str] = [f"{time}"] + if isclose( + time, + meniscus_time, + rel_tol=0.001, + ): + pressure_row.append("Meniscus") + else: + pressure_row.append("") + for trial in range(trials): + if i < len(pressure_results[tip][trial]): + pressure_row.append(f"{pressure_results[tip][trial][i]}") + else: + pressure_row.append("") + pressure_row.append( + f"{results_settings[tip][trial][0] * time - tip_offsets[tip][trial]}" + ) + pressure_row.append( + f"{abs(results_settings[tip][trial][1]) * time + p_offsets[tip][trial]}" + ) + final_report_writer.writerow(pressure_row) + time += 0.001 + + +if __name__ == "__main__": + process_csv_directory("/home/ryan/testdata", [50], 10) diff --git a/hardware-testing/hardware_testing/liquid_sense/report.py b/hardware-testing/hardware_testing/liquid_sense/report.py new file mode 100644 index 00000000000..bca898e79c7 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/report.py @@ -0,0 +1,263 @@ +"""Format the csv report for a liquid-sense run.""" + +import statistics +from hardware_testing.data.csv_report import ( + CSVReport, + CSVSection, + CSVLine, + CSVLineRepeating, +) +from typing import List, Union + +""" +CSV Test Report: + - Serial numbers: + - Robot + - Pipette + - Scale + - Environment sensor + - Config: + - protocol name + - pipette_volume + - pipette_mount + - tip_volume + - trials + - plunger direction + - liquid + - labware type + - speed + - start height offset + - Trials + trial-x-{tipsize}ul + - Results + {tipsize}ul-average + {tipsize}ul-cv + {tipsize}ul-d +""" + + +def build_serial_number_section() -> CSVSection: + """Build section.""" + return CSVSection( + title="SERIAL-NUMBERS", + lines=[ + CSVLine("robot", [str]), + CSVLine("git_description", [str]), + CSVLine("pipette", [str]), + CSVLine("scale", [str]), + CSVLine("environment", [str]), + ], + ) + + +def build_config_section() -> CSVSection: + """Build section.""" + return CSVSection( + title="CONFIG", + lines=[ + CSVLine("protocol_name", [str]), + CSVLine("pipette_volume", [str]), + CSVLine("tip_volume", [bool, bool, bool]), + CSVLine("trials", [str]), + CSVLine("plunger_direction", [str]), + CSVLine("liquid", [str]), + CSVLine("labware_type", [str]), + CSVLine("speed", [str]), + CSVLine("start_height_offset", [str]), + ], + ) + + +def build_trials_section(trials: int, tips: List[int]) -> CSVSection: + """Build section.""" + lines: List[Union[CSVLine, CSVLineRepeating]] = [ + CSVLine("trial_number", [str, str, str, str, str, str, str, str]) + ] + lines.extend( + [ + CSVLine( + f"trial-baseline-{tip}ul", + [float, float, float, float, float, float, float, float], + ) + for tip in tips + ] + ) + lines.extend( + [ + CSVLine( + f"trial-{t + 1}-{tip}ul", + [float, float, float, float, float, float, float, float], + ) + for tip in tips + for t in range(trials) + ] + ) + + return CSVSection( + title="TRIALS", + lines=lines, + ) + + +def build_results_section(tips: List[int]) -> CSVSection: + """Build section.""" + lines: List[CSVLine] = [] + for tip in tips: + lines.append(CSVLine(f"{tip}ul-average", [float])) + lines.append(CSVLine(f"{tip}ul-minumum", [float])) + lines.append(CSVLine(f"{tip}ul-maximum", [float])) + lines.append(CSVLine(f"{tip}ul-stdev", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-average", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-minumum", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-maximum", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-stdev", [float])) + return CSVSection(title="RESULTS", lines=lines) # type: ignore[arg-type] + + +def store_serial_numbers( + report: CSVReport, + robot: str, + pipette: str, + scale: str, + environment: str, + git_description: str, +) -> None: + """Report serial numbers.""" + report("SERIAL-NUMBERS", "robot", [robot]) + report("SERIAL-NUMBERS", "git_description", [git_description]) + report("SERIAL-NUMBERS", "pipette", [pipette]) + report("SERIAL-NUMBERS", "scale", [scale]) + report("SERIAL-NUMBERS", "environment", [environment]) + + +def store_config( + report: CSVReport, + protocol_name: str, + pipette_volume: str, + tip_volumes: List[int], + trials: int, + plunger_direction: str, + liquid: str, + labware_type: str, + speed: str, + start_height_offset: str, +) -> None: + """Report config.""" + report("CONFIG", "protocol_name", [protocol_name]) + report("CONFIG", "pipette_volume", [pipette_volume]) + report( + "CONFIG", + "tip_volume", + [50 in tip_volumes, 200 in tip_volumes, 1000 in tip_volumes], + ) + report("CONFIG", "trials", [trials]) + report("CONFIG", "plunger_direction", [plunger_direction]) + report("CONFIG", "liquid", [liquid]) + report("CONFIG", "labware_type", [labware_type]) + report("CONFIG", "speed", [speed]) + report("CONFIG", "start_height_offset", [start_height_offset]) + + +def store_baseline_trial( + report: CSVReport, + tip: float, + height: float, + humidity: float, + temp: float, + z_travel: float, + measured_error: float, +) -> None: + """Report Trial.""" + report( + "TRIALS", + f"trial-baseline-{tip}ul", + [ + height, + 0, + humidity, + temp, + z_travel, + 0, + 0, + measured_error, + ], + ) + + +def store_trial( + report: CSVReport, + trial: int, + tip: float, + height: float, + plunger_pos: float, + humidity: float, + temp: float, + z_travel: float, + plunger_travel: float, + tip_length_offset: float, +) -> None: + """Report Trial.""" + report( + "TRIALS", + f"trial-{trial + 1}-{tip}ul", + [ + height, + plunger_pos, + humidity, + temp, + z_travel, + plunger_travel, + tip_length_offset, + height + tip_length_offset, + ], + ) + + +def store_tip_results( + report: CSVReport, tip: float, results: List[float], adjusted_results: List[float] +) -> None: + """Store final results.""" + report("RESULTS", f"{tip}ul-average", [sum(results) / len(results)]) + report("RESULTS", f"{tip}ul-minumum", [min(results)]) + report("RESULTS", f"{tip}ul-maximum", [max(results)]) + report("RESULTS", f"{tip}ul-stdev", [statistics.stdev(results)]) + report( + "RESULTS", + f"{tip}ul-adjusted-average", + [sum(adjusted_results) / len(adjusted_results)], + ) + report("RESULTS", f"{tip}ul-adjusted-minumum", [min(adjusted_results)]) + report("RESULTS", f"{tip}ul-adjusted-maximum", [max(adjusted_results)]) + report("RESULTS", f"{tip}ul-adjusted-stdev", [statistics.stdev(adjusted_results)]) + + +def build_ls_report( + test_name: str, run_id: str, trials: int, tips: List[int] +) -> CSVReport: + """Generate a CSV Report.""" + report = CSVReport( + test_name=test_name, + sections=[ + build_serial_number_section(), + build_config_section(), + build_trials_section(trials, tips), + build_results_section(tips), + ], + run_id=run_id, + start_time=0.0, + ) + report( + "TRIALS", + "trial_number", + [ + "height", + "plunger_pos", + "humidity", + "temp", + "z_travel", + "plunger_travel", + "tip_length_offset", + "adjusted_height", + ], + ) + return report diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index f277ff93f76..d1ff8f91d53 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -84,9 +84,7 @@ def stop_server_ot3() -> None: def restart_server_ot3() -> None: """Start opentrons-robot-server on the OT3.""" print('Starting "opentrons-robot-server"...') - Popen( - ["systemctl", "restart", "opentrons-robot-server", "&"], - ) + Popen(["systemctl restart opentrons-robot-server &"], shell=True) def start_server_ot3() -> None: diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py new file mode 100644 index 00000000000..6ec34e45de0 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py @@ -0,0 +1 @@ +"""Liquid Sense LPC.""" diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py new file mode 100644 index 00000000000..02644b314a4 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py @@ -0,0 +1,33 @@ +"""Liquid sense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-96"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + # TODO: add slot 12 when tipracks are disposable + 50: [2, 3, 6, 7, 8, 9, 10, 11], + 200: [2, 3, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration + 1000: [2, 3, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration +} + +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + if size == 50 # only calibrate 50ul tip-racks + ] + scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("p1000_96", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, scale_labware["A1"].top()) + pipette.dispense(10, scale_labware["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py new file mode 100644 index 00000000000..d2b806d1229 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py @@ -0,0 +1,26 @@ +"""LiquidSense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-multi"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = {50: [2], 200: [3], 1000: [6]} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("flex_8channel_1000", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py new file mode 100644 index 00000000000..4e8fcc177f4 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py @@ -0,0 +1,33 @@ +"""Liquid Sense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], + 200: [6], + 1000: [9], +} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + dial = ctx.load_labware("dial_indicator", SLOT_DIAL) + pipette = ctx.load_instrument("flex_1channel_1000", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.aspirate(1, dial["A1"].top()) + pipette.dispense(1, dial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py new file mode 100644 index 00000000000..34f83cd4cf7 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py @@ -0,0 +1,28 @@ +"""Liquid Sense OT3.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid_sense-ot3-p50-multi-50ul-tip"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], +} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("flex_8channel_50", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(pipette.min_volume, vial["A1"].top()) + pipette.dispense(pipette.min_volume, vial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py new file mode 100644 index 00000000000..8e9d65a72e2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py @@ -0,0 +1,31 @@ +"""Liquid Sense OT3.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p50-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], +} +LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + dial = ctx.load_labware("dial_indicator", SLOT_DIAL) + pipette = ctx.load_instrument("flex_1channel_50", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.aspirate(1, dial["A1"].top()) + pipette.dispense(1, dial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py index 6611edecfe4..9906aa8dc07 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py @@ -111,6 +111,7 @@ defs.GetHepaUVStateResponse, defs.SendAccumulatedPressureDataRequest, defs.AddSensorLinearMoveRequest, + defs.SendAccumulatedPressureDataRequest, ] diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 94301464f22..67e85a1554b 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -201,7 +201,6 @@ async def liquid_probe( csv_output: bool = False, sync_buffer_output: bool = False, can_bus_only_output: bool = False, - # output_option: OutputOptions, data_file: Optional[str] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, @@ -232,7 +231,7 @@ async def liquid_probe( ) sensor_runner = MoveGroupRunner(move_groups=[[sensor_group]]) - log_file: str = "/var/pressure_sensor_data.csv" if not data_file else data_file + log_file: str = "/data/pressure_sensor_data.csv" if not data_file else data_file if csv_output: return await run_stream_output_to_csv( messenger, From 0c799fec1ab8df32918633ccf015c396ca18ab8d Mon Sep 17 00:00:00 2001 From: Rhyann Clarke <146747548+rclarke0@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:09:41 -0400 Subject: [PATCH 20/49] Add errored runs to abr tracking sheet (#14845) # Overview Improved ABR Error Data Collection # Test Plan Tested code on multiple robots. # Changelog Added function to download robot logs Added lines of code to move error documents (run log, calibration log, robot logs) into folder named after ticket. Adds robot run to ABR sheet and links JIRA ticket Added extra lines to abr_scale to read scale more often Edited ABR calibration script to ensure duplicate calibrations are not added. # Review requests Is 5000 lines of recording enough to capture robot error if script is run immediately? Is there any manipulation to robot logs that can be down to make error analysis more efficient. # Risk assessment --- .../automation/google_drive_tool.py | 1 - .../automation/google_sheets_tool.py | 7 ++ .../abr_testing/automation/jira_tool.py | 11 +-- .../data_collection/abr_calibration_logs.py | 32 ++++++--- .../data_collection/abr_google_drive.py | 26 +++++-- .../abr_testing/data_collection/abr_lpc.py | 1 + .../data_collection/abr_robot_error.py | 67 ++++++++++++++++--- .../data_collection/read_robot_logs.py | 63 +++++++++++++++-- abr-testing/abr_testing/tools/abr_scale.py | 7 ++ 9 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 abr-testing/abr_testing/data_collection/abr_lpc.py diff --git a/abr-testing/abr_testing/automation/google_drive_tool.py b/abr-testing/abr_testing/automation/google_drive_tool.py index 8b56d0390fe..3b65456d0ff 100644 --- a/abr-testing/abr_testing/automation/google_drive_tool.py +++ b/abr-testing/abr_testing/automation/google_drive_tool.py @@ -25,7 +25,6 @@ def __init__(self, credentials: Any, folder_name: str, email: str) -> None: self.drive_service = build("drive", "v3", credentials=self.credentials) self.parent_folder = folder_name self.email = email - self.folder = self.open_folder() def list_folder(self, delete: Any = False) -> Set[str]: """List folders and files in Google Drive.""" diff --git a/abr-testing/abr_testing/automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py index e486a28fed2..af38a39dcc0 100644 --- a/abr-testing/abr_testing/automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -2,6 +2,7 @@ import gspread # type: ignore[import] import socket import httplib2 +from datetime import datetime from oauth2client.service_account import ServiceAccountCredentials # type: ignore[import] from typing import Dict, List, Any, Set, Tuple @@ -57,6 +58,12 @@ def write_to_row(self, data: List) -> None: """Write data into a row in a List[] format.""" try: self.row_index += 1 + data = [ + item.strftime("%Y/%m/%d %H:%M:%S") + if isinstance(item, datetime) + else item + for item in data + ] self.worksheet.insert_row(data, index=self.row_index) except socket.gaierror: pass diff --git a/abr-testing/abr_testing/automation/jira_tool.py b/abr-testing/abr_testing/automation/jira_tool.py index aff3a6798c3..5c0a2556dfb 100644 --- a/abr-testing/abr_testing/automation/jira_tool.py +++ b/abr-testing/abr_testing/automation/jira_tool.py @@ -5,7 +5,7 @@ import json import webbrowser import argparse -from typing import List, Tuple +from typing import List class JiraTicket: @@ -41,11 +41,12 @@ def issues_on_board(self, board_id: str) -> List[str]: issue_ids.append(issue_id) return issue_ids - def open_issue(self, issue_key: str) -> None: + def open_issue(self, issue_key: str) -> str: """Open issue on web browser.""" url = f"{self.url}/browse/{issue_key}" print(f"Opening at {url}.") webbrowser.open(url) + return url def create_ticket( self, @@ -58,7 +59,7 @@ def create_ticket( components: list, affects_versions: str, robot: str, - ) -> Tuple[str, str]: + ) -> str: """Create ticket.""" data = { "fields": { @@ -94,13 +95,15 @@ def create_ticket( response_str = str(response.content) issue_url = response.json().get("self") issue_key = response.json().get("key") + print(f"issue key {issue_key}") + print(f"issue url{issue_url}") if issue_key is None: print("Error: Could not create issue. No key returned.") except requests.exceptions.HTTPError: print(f"HTTP error occurred. Response content: {response_str}") except json.JSONDecodeError: print(f"JSON decoding error occurred. Response content: {response_str}") - return issue_url, issue_key + return issue_key def post_attachment_to_ticket(self, issue_id: str, attachment_path: str) -> None: """Adds attachments to ticket.""" 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 6e897dd78eb..4d744b5b2f5 100644 --- a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -1,5 +1,5 @@ """Get Calibration logs from robots.""" -from typing import Dict, Any, List +from typing import Dict, Any, List, Union import argparse import os import json @@ -16,15 +16,18 @@ def check_for_duplicates( col_2: int, row: List[str], headers: List[str], -) -> List[str]: +) -> Union[List[str], None]: """Check google sheet for duplicates.""" serials = google_sheet.get_column(col_1) modify_dates = google_sheet.get_column(col_2) - for serial, modify_date in zip(serials, modify_dates): - if row[col_1 - 1] == serial and row[col_2 - 1] == modify_date: - print(f"Skipped row{row}. Already on Google Sheet.") - continue - read_robot_logs.write_to_sheets(sheet_location, google_sheet, row, headers) + # check for complete calibration. + if len(row[-1]) > 0: + for serial, modify_date in zip(serials, modify_dates): + if row[col_1 - 1] == serial and row[col_2 - 1] == modify_date: + print(f"Skipped row for instrument {serial}. Already on Google Sheet.") + return None + read_robot_logs.write_to_sheets(sheet_location, google_sheet, row, headers) + print(f"Writing calibration for: {serial}") return row @@ -64,6 +67,7 @@ def upload_calibration_offsets( instrument_row, instrument_headers, ) + # MODULE SHEET if len(calibration.get("Modules", "")) > 0: module_headers = ( @@ -198,13 +202,19 @@ def upload_calibration_offsets( except FileNotFoundError: print(f"Add .json file with robot IPs to: {storage_directory}.") sys.exit() + if ip_or_all == "ALL": ip_address_list = ip_file["ip_address_list"] for ip in ip_address_list: - saved_file_path, calibration = read_robot_logs.get_calibration_offsets( - ip, storage_directory - ) - upload_calibration_offsets(calibration, storage_directory) + print(ip) + try: + saved_file_path, calibration = read_robot_logs.get_calibration_offsets( + ip, storage_directory + ) + upload_calibration_offsets(calibration, storage_directory) + except Exception: + print(f"ERROR: Failed to read IP address: {ip}") + continue else: saved_file_path, calibration = read_robot_logs.get_calibration_offsets( ip_or_all, storage_directory diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index 741ac871d62..6470f1e0410 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -6,7 +6,7 @@ import gspread # type: ignore[import] from datetime import datetime, timedelta from abr_testing.data_collection import read_robot_logs -from typing import Set, Dict, Any, Tuple, List +from typing import Set, Dict, Any, Tuple, List, Union from abr_testing.automation import google_drive_tool, google_sheets_tool @@ -30,7 +30,9 @@ def get_modules(file_results: Dict[str, str]) -> Dict[str, Any]: def create_data_dictionary( - runs_to_save: Set[str], storage_directory: str + runs_to_save: Union[Set[str], str], + storage_directory: str, + issue_url: str, ) -> Tuple[Dict[Any, Dict[str, Any]], List]: """Pull data from run files and format into a dictionary.""" runs_and_robots = {} @@ -41,7 +43,7 @@ def create_data_dictionary( file_results = json.load(file) else: continue - run_id = file_results.get("run_id") + run_id = file_results.get("run_id", "NaN") if run_id in runs_to_save: robot = file_results.get("robot_name") protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") @@ -56,6 +58,7 @@ def create_data_dictionary( error_instrument, error_level, ) = read_robot_logs.get_error_info(file_results) + all_modules = get_modules(file_results) start_time_str, complete_time_str, start_date, run_time_min = ( @@ -103,13 +106,14 @@ def create_data_dictionary( tc_dict = read_robot_logs.thermocycler_commands(file_results) hs_dict = read_robot_logs.hs_commands(file_results) tm_dict = read_robot_logs.temperature_module_commands(file_results) - notes = {"Note1": "", "Note2": ""} + notes = {"Note1": "", "Jira Link": issue_url} row_2 = {**row, **all_modules, **notes, **hs_dict, **tm_dict, **tc_dict} headers = list(row_2.keys()) runs_and_robots[run_id] = row_2 else: - os.remove(file_path) - print(f"Run ID: {run_id} has a run time of 0 minutes. Run removed.") + continue + # os.remove(file_path) + # print(f"Run ID: {run_id} has a run time of 0 minutes. Run removed.") return runs_and_robots, headers @@ -168,6 +172,14 @@ def create_data_dictionary( except gspread.exceptions.APIError: print("ERROR: Check google sheet name. Check credentials file.") sys.exit() + try: + google_sheet_lpc = google_sheets_tool.google_sheet( + credentials_path, "ABR-LPC", 0 + ) + print("Connected to google sheet ABR-LPC") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() run_ids_on_gs = google_sheet.get_column(2) run_ids_on_gs = set(run_ids_on_gs) @@ -181,7 +193,7 @@ def create_data_dictionary( ) # Add missing runs to google sheet runs_and_robots, headers = create_data_dictionary( - missing_runs_from_gs, storage_directory + missing_runs_from_gs, storage_directory, "" ) read_robot_logs.write_to_local_and_google_sheet( runs_and_robots, storage_directory, google_sheet_name, google_sheet, headers diff --git a/abr-testing/abr_testing/data_collection/abr_lpc.py b/abr-testing/abr_testing/data_collection/abr_lpc.py new file mode 100644 index 00000000000..dd880d09c37 --- /dev/null +++ b/abr-testing/abr_testing/data_collection/abr_lpc.py @@ -0,0 +1 @@ +"""Get Unique LPC Values from Run logs.""" diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index 3f7302e8725..b139b5a3ade 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -3,7 +3,13 @@ from abr_testing.data_collection import read_robot_logs, abr_google_drive, get_run_logs import requests import argparse -from abr_testing.automation import jira_tool +from abr_testing.automation import jira_tool, google_sheets_tool, google_drive_tool +import shutil +import os +import subprocess +import json +import sys +import gspread # type: ignore[import] def get_error_runs_from_robot(ip: str) -> List[str]: @@ -44,7 +50,6 @@ def get_error_info_from_robot( # JIRA Ticket Fields failure_level = "Level " + str(error_level) + " Failure" components = [failure_level, "Flex-RABR"] - components = ["Flex-RABR"] affects_version = results["API_Version"] parent = results.get("robot_name", "") print(parent) @@ -140,18 +145,19 @@ def get_error_info_from_robot( affects_version, components, whole_description_str, - saved_file_path, + run_log_file_path, ) = get_error_info_from_robot(ip, one_run, storage_directory) # get calibration data saved_file_path_calibration, calibration = read_robot_logs.get_calibration_offsets( ip, storage_directory ) + file_paths = read_robot_logs.get_logs(storage_directory, ip) print(f"Making ticket for run: {one_run} on robot {robot}.") # TODO: make argument or see if I can get rid of with using board_id. project_key = "RABR" parent_key = project_key + "-" + robot[-1] - issues_ids = ticket.issues_on_board(board_id) - issue_url, issue_key = ticket.create_ticket( + # CREATE TICKET + issue_key = ticket.create_ticket( summary, whole_description_str, project_key, @@ -162,6 +168,51 @@ def get_error_info_from_robot( affects_version, parent_key, ) - ticket.open_issue(issue_key) - ticket.post_attachment_to_ticket(issue_key, saved_file_path) - ticket.post_attachment_to_ticket(issue_key, saved_file_path_calibration) + # OPEN TICKET + issue_url = ticket.open_issue(issue_key) + # MOVE FILES TO ERROR FOLDER. + error_files = [saved_file_path_calibration, run_log_file_path] + file_paths + error_folder_path = os.path.join(storage_directory, str("RABR-238")) + os.makedirs(error_folder_path, exist_ok=True) + for source_file in error_files: + destination_file = os.path.join( + error_folder_path, os.path.basename(source_file) + ) + shutil.move(source_file, destination_file) + # OPEN FOLDER DIRECTORY + subprocess.Popen(["explorer", error_folder_path]) + # CONNECT TO GOOGLE DRIVE + credentials_path = os.path.join(storage_directory, "credentials.json") + google_sheet_name = "ABR-run-data" + try: + google_drive = google_drive_tool.google_drive( + credentials_path, + "1Cvej0eadFOTZr9ILRXJ0Wg65ymOtxL4m", + "rhyann.clarke@opentrons.ocm", + ) + print("Connected to google drive.") + except json.decoder.JSONDecodeError: + print( + "Credential file is damaged. Get from https://console.cloud.google.com/apis/credentials" + ) + sys.exit() + # CONNECT TO GOOGLE SHEET + try: + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + print(f"Connected to google sheet: {google_sheet_name}") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() + # WRITE ERRORED RUN TO GOOGLE SHEET + error_run_log = os.path.join(error_folder_path, os.path.basename(run_log_file_path)) + google_drive.upload_file(error_run_log) + run_id = os.path.basename(error_run_log).split("_")[1].split(".")[0] + runs_and_robots, headers = abr_google_drive.create_data_dictionary( + run_id, error_folder_path, issue_url + ) + read_robot_logs.write_to_local_and_google_sheet( + runs_and_robots, storage_directory, google_sheet_name, google_sheet, headers + ) + print("Wrote run to ABR-run-data") diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index 0e31603b7da..48ef1d20163 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -14,6 +14,35 @@ import requests +def lpc_data(file_results: Dict[str, Any], protocol_info: Dict) -> List[Dict[str, Any]]: + """Get labware offsets from one run log.""" + offsets = file_results.get("labwareOffsets", "") + all_offsets: List[Dict[str, Any]] = [] + if len(offsets) > 0: + for offset in offsets: + labware_type = offset.get("definitionUri", "") + slot = offset["location"].get("slotName", "") + module_location = offset["location"].get("moduleModel", "") + adapter = offset["location"].get("definitionUri", "") + x_offset = offset["vector"].get("x", 0.0) + y_offset = offset["vector"].get("y", 0.0) + z_offset = offset["vector"].get("z", 0.0) + created_at = offset.get("createdAt", "") + row = { + "createdAt": created_at, + "Labware Type": labware_type, + "Slot": slot, + "Module": module_location, + "Adapter": adapter, + "X": x_offset, + "Y": y_offset, + "Z": z_offset, + } + row2 = {**protocol_info, **row} + all_offsets.append(row2) + return all_offsets + + def command_time(command: Dict[str, str]) -> Tuple[float, float]: """Calculate total create and complete time per command.""" try: @@ -82,11 +111,11 @@ def hs_commands(file_results: Dict[str, Any]) -> Dict[str, float]: temp_time = datetime.strptime( command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" ) - + hs_latch_sets = hs_latch_count / 2 # one set of open/close hs_total_rotations = sum(hs_rotations.values()) hs_total_temp_time = sum(hs_temps.values()) hs_dict = { - "Heatershaker # of Latch Engagements": hs_latch_count, + "Heatershaker # of Latch Open/Close": hs_latch_sets, "Heatershaker # of Homes": hs_home_count, "Heatershaker # of Rotations": hs_total_rotations, "Heatershaker Temp On Time (sec)": hs_total_temp_time, @@ -206,9 +235,9 @@ def thermocycler_commands(file_results: Dict[str, Any]) -> Dict[str, float]: block_total_time = sum(block_temps.values()) lid_total_time = sum(lid_temps.values()) - + lid_sets = lid_engagements / 2 tc_dict = { - "Thermocycler # of Lid Engagements": lid_engagements, + "Thermocycler # of Lid Open/Close": lid_sets, "Thermocycler Block # of Temp Changes": block_temp_changes, "Thermocycler Block Temp On Time (sec)": block_total_time, "Thermocycler Lid # of Temp Changes": lid_temp_changes, @@ -223,7 +252,6 @@ def create_abr_data_sheet( ) -> str: """Creates csv file to log ABR data.""" file_name_csv = file_name + ".csv" - print(file_name_csv) sheet_location = os.path.join(storage_directory, file_name_csv) if os.path.exists(sheet_location): print(f"File {sheet_location} located. Not overwriting.") @@ -427,3 +455,28 @@ def get_calibration_offsets( saved_file_path = os.path.join(storage_directory, save_name) json.dump(calibration, open(saved_file_path, mode="w")) return saved_file_path, calibration + + +def get_logs(storage_directory: str, ip: str) -> List[str]: + """Get Robot logs.""" + log_types = ["api.log", "server.log", "serial.log", "touchscreen.log"] + all_paths = [] + for log_type in log_types: + try: + response = requests.get( + f"http://{ip}:31950/logs/{log_type}", + headers={"log_identifier": log_type}, + params={"records": 5000}, + ) + response.raise_for_status() + log_data = response.text + log_name = ip + "_" + log_type.split(".")[0] + ".json" + file_path = os.path.join(storage_directory, log_name) + with open(file_path, mode="w", encoding="utf-8") as file: + file.write(response.text) + json.dump(log_data, open(file_path, mode="w")) + except RuntimeError: + print(f"Request exception. Did not save {log_type}") + continue + all_paths.append(file_path) + return all_paths diff --git a/abr-testing/abr_testing/tools/abr_scale.py b/abr-testing/abr_testing/tools/abr_scale.py index 0947091fe4b..75c887d4ecc 100644 --- a/abr-testing/abr_testing/tools/abr_scale.py +++ b/abr-testing/abr_testing/tools/abr_scale.py @@ -73,8 +73,12 @@ print("No google sheets credentials. Add credentials to storage notebook.") # Scale Loop + grams, is_stable = scale.read_mass() + grams, is_stable = scale.read_mass() + is_stable = False break_all = False while is_stable is False: + grams, is_stable = scale.read_mass() grams, is_stable = scale.read_mass() print(f"Scale reading: grams={grams}, is_stable={is_stable}") time_now = datetime.datetime.now() @@ -90,9 +94,12 @@ y_or_no = input("Do you want to weigh another sample? (Y/N): ") if y_or_no == "Y": # Uses same storage directory and file. + grams, is_stable = scale.read_mass() + is_stable = False robot = input("Robot: ") labware = input("Labware: ") protocol_step = input("Measurement Step (1,2,3): ") + grams, is_stable = scale.read_mass() elif y_or_no == "N": break_all = True if break_all: From f0398974be58d96bc3465e858be17376969c6d4e Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Tue, 9 Apr 2024 13:34:21 -0500 Subject: [PATCH 21/49] App style 96 ch exit text (#14843) Into edge instead of release branch. Was #14840 Co-authored-by: Jamey Huffnagle --- app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx | 2 +- .../PipetteWizardFlows/__tests__/UnskippableModal.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx b/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx index 5355349b656..497e5fc19b0 100644 --- a/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx +++ b/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx @@ -32,7 +32,7 @@ export function UnskippableModal(props: UnskippableModalProps): JSX.Element { diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx index fd28aa5e8df..43fa441c7d1 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx @@ -41,7 +41,9 @@ describe('UnskippableModal', () => { screen.getByText( 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' ) - fireEvent.click(screen.getByRole('button', { name: 'exit' })) + screen.getByText('Exit') + screen.getByText('Go back') + fireEvent.click(screen.getByRole('button', { name: 'Exit' })) expect(props.proceed).toHaveBeenCalled() }) }) From e345d32ab0507cbd86bd697b1fcdce7c99177e94 Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 9 Apr 2024 14:41:30 -0400 Subject: [PATCH 22/49] fix(shared-data, app): fix runtime parameters range display (#14847) * fix(shared-data, app): fix runtime parameters range display --- app/src/pages/ProtocolDetails/Parameters.tsx | 7 +-- .../src/molecules/ParametersTable/index.tsx | 3 +- .../orderRuntimeParameterRangeOptions.test.ts | 50 +++++++++++++++++++ shared-data/js/helpers/index.ts | 1 + .../orderRuntimeParameterRangeOptions.ts | 46 +++++++++++++++++ shared-data/js/types.ts | 2 +- 6 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts create mode 100644 shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts diff --git a/app/src/pages/ProtocolDetails/Parameters.tsx b/app/src/pages/ProtocolDetails/Parameters.tsx index b908b5b84d7..0b280a2af3d 100644 --- a/app/src/pages/ProtocolDetails/Parameters.tsx +++ b/app/src/pages/ProtocolDetails/Parameters.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components' import { formatRunTimeParameterDefaultValue, formatRunTimeParameterMinMax, + orderRuntimeParameterRangeOptions, } from '@opentrons/shared-data' import { BORDERS, @@ -62,13 +63,13 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { makeSnackbar(t('start_setup_customize_values')) } - const getRange = (parameter: RunTimeParameter): string => { + const formatRange = (parameter: RunTimeParameter): string => { const { type } = parameter const numChoices = 'choices' in parameter ? parameter.choices.length : 0 const minMax = formatRunTimeParameterMinMax(parameter) let range: string | null = null if (numChoices === 2 && 'choices' in parameter) { - range = `${parameter.choices[0].displayName}, ${parameter.choices[1].displayName}` + range = orderRuntimeParameterRangeOptions(parameter.choices) } switch (type) { @@ -125,7 +126,7 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { - {getRange(parameter)} + {formatRange(parameter)} diff --git a/components/src/molecules/ParametersTable/index.tsx b/components/src/molecules/ParametersTable/index.tsx index 485a5efc6e5..5ae0d36d550 100644 --- a/components/src/molecules/ParametersTable/index.tsx +++ b/components/src/molecules/ParametersTable/index.tsx @@ -3,6 +3,7 @@ import styled, { css } from 'styled-components' import { formatRunTimeParameterDefaultValue, formatRunTimeParameterMinMax, + orderRuntimeParameterRangeOptions, } from '@opentrons/shared-data' import { BORDERS, COLORS } from '../../helix-design-system' import { SPACING, TYPOGRAPHY } from '../../ui-style-constants/index' @@ -38,7 +39,7 @@ export function ParametersTable({ ? t != null ? t('num_options', { num: count }) : `${count} options` - : choices.map(choice => choice.displayName).join(', ') + : orderRuntimeParameterRangeOptions(choices) } switch (type) { diff --git a/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts b/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts new file mode 100644 index 00000000000..2a5b62b265d --- /dev/null +++ b/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' + +import { + isNumeric, + orderRuntimeParameterRangeOptions, +} from '../orderRuntimeParameterRangeOptions' + +import type { Choice } from '../../types' + +describe('isNumeric', () => { + it('should return true when input is "2"', () => { + const result = isNumeric('2') + expect(result).toBeTruthy() + }) + + it('should return false when input is "opentrons"', () => { + const result = isNumeric('opentrons') + expect(result).toBeFalsy() + }) +}) + +describe('orderRuntimeParameterRangeOptions', () => { + it('should return numerical order when choices are number', () => { + const mockChoices: Choice[] = [ + { displayName: '20', value: 20 }, + { displayName: '16', value: 16 }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('16, 20') + }) + + it('should return alphabetical order when choices are number', () => { + const mockChoices: Choice[] = [ + { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, + { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('Eight Channel 50µL, Single channel 50µL') + }) + + it('should return empty string choices > 3', () => { + const mockChoices: Choice[] = [ + { displayName: '20', value: 20 }, + { displayName: '16', value: 16 }, + { displayName: '18', value: 18 }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('') + }) +}) diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index 854b82d5133..0cb4ec7d88a 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -31,6 +31,7 @@ export * from './getSimplestFlexDeckConfig' export * from './formatRunTimeParameterDefaultValue' export * from './formatRunTimeParameterValue' export * from './formatRunTimeParameterMinMax' +export * from './orderRuntimeParameterRangeOptions' export const getLabwareDefIsStandard = (def: LabwareDefinition2): boolean => def?.namespace === OPENTRONS_LABWARE_NAMESPACE diff --git a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts new file mode 100644 index 00000000000..c372e992a2b --- /dev/null +++ b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts @@ -0,0 +1,46 @@ +import type { Choice } from '../types' + +export const isNumeric = (str: string): boolean => { + return !isNaN(Number(str)) +} + +/** + * This function sorts an array of strings in numerical and alphabetical order. + * @param {Choice[]} - The array of Choice + * Choice is an object like {displayName: 'Single channel 50µL', value: 'flex_1channel_50' } + * @returns {string} The ordered string with "," + * + * examples + * [ + { displayName: '20', value: 20 }, + { displayName: '16', value: 16 }, + ] + return 16, 20 + + [ + { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, + { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, + ] + return Eight Channel 50µL, Single channel 50µL + */ +export const orderRuntimeParameterRangeOptions = ( + choices: Choice[] +): string => { + // when this function is called, the array length is always 2 + if (choices.length > 2) { + console.error(`expected to have length 2 but has length ${choices.length}`) + return '' + } + const displayNames = [choices[0].displayName, choices[1].displayName] + if (isNumeric(displayNames[0])) { + return displayNames + .sort((a, b) => { + const numA = Number(a) + const numB = Number(b) + return numA - numB + }) + .join(', ') + } else { + return displayNames.sort().join(', ') + } +} diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 13fa4491a43..75466e7558e 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -597,7 +597,7 @@ export interface NumberParameter { default: number } -interface Choice { +export interface Choice { displayName: string value: number | boolean | string } From 2a82fef538b2996c12ede8c5e2ffc1d82578bdc3 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 9 Apr 2024 14:45:56 -0400 Subject: [PATCH 23/49] style(app): Adjust desktop app "moveToWell" command text font size (#14849) Closes RQA-2428 --- app/src/organisms/CommandText/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/organisms/CommandText/index.tsx b/app/src/organisms/CommandText/index.tsx index 06eae754759..47c54140149 100644 --- a/app/src/organisms/CommandText/index.tsx +++ b/app/src/organisms/CommandText/index.tsx @@ -190,11 +190,15 @@ export function CommandText(props: Props): JSX.Element | null { robotType ) : '' - return t('move_to_well', { - well_name: wellName, - labware: getLabwareName(robotSideAnalysis, labwareId), - labware_location: displayLocation, - }) + return ( + + {t('move_to_well', { + well_name: wellName, + labware: getLabwareName(robotSideAnalysis, labwareId), + labware_location: displayLocation, + })} + + ) } case 'moveLabware': { return ( From 30125683a0ae584b9281ceff85aea208428d1e5b Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Tue, 9 Apr 2024 15:09:00 -0400 Subject: [PATCH 24/49] refactor(app): switch ODD update modal progress bar with spinner (#14838) closes RQA-2553 --- .../UpdateRobotSoftware/UpdateSoftware.tsx | 16 ++++++------- .../__tests__/UpdateRobotSoftware.test.tsx | 2 +- .../__tests__/UpdateSoftware.test.tsx | 24 ++++--------------- .../organisms/UpdateRobotSoftware/index.tsx | 9 ++----- 4 files changed, 16 insertions(+), 35 deletions(-) diff --git a/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx b/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx index 60ff6cc18de..7d625254a2f 100644 --- a/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx +++ b/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx @@ -4,25 +4,21 @@ import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, BORDERS, - Box, COLORS, DIRECTION_COLUMN, Flex, + Icon, JUSTIFY_CENTER, SPACING, StyledText, TYPOGRAPHY, } from '@opentrons/components' -import { ProgressBar } from '../../atoms/ProgressBar' - interface UpdateSoftwareProps { updateType: 'downloading' | 'validating' | 'sendingFile' | 'installing' | null - processProgress: number } export function UpdateSoftware({ updateType, - processProgress, }: UpdateSoftwareProps): JSX.Element { const { t } = useTranslation('device_settings') const renderText = (): string | null => { @@ -52,6 +48,13 @@ export function UpdateSoftware({ height="33rem" borderRadius={BORDERS.borderRadius12} > + - - -
    ) } diff --git a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx index 242b40c4be8..5db3c1358eb 100644 --- a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx +++ b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx @@ -113,7 +113,7 @@ describe('UpdateRobotSoftware', () => { render() expect(mockBeforeCommitting).toBeCalled() expect(UpdateSoftware).toBeCalledWith( - { updateType: 'installing', processProgress: 0 }, + { updateType: 'installing' }, expect.anything() ) screen.getByText('mock UpdateSoftware') diff --git a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx index 913f2c26dea..680de1b0147 100644 --- a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx +++ b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx @@ -1,9 +1,8 @@ import * as React from 'react' import { screen } from '@testing-library/react' -import { describe, it, beforeEach, expect } from 'vitest' +import { describe, it, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { renderWithProviders } from '../../../__testing-utils__' -import { COLORS } from '@opentrons/components' import { i18n } from '../../../i18n' import { UpdateSoftware } from '../UpdateSoftware' @@ -18,47 +17,34 @@ describe('UpdateSoftware', () => { beforeEach(() => { props = { updateType: 'downloading', - processProgress: 50, } }) - it('should render text and progressbar - downloading software', () => { + it('should render text - downloading software', () => { render(props) screen.getByText('Downloading software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle(`background: ${String(COLORS.blue50)}`) - expect(bar).toHaveStyle('width: 50%') }) - it('should render text and progressbar - sending software', () => { + it('should render text - sending software', () => { props = { ...props, - processProgress: 20, updateType: 'sendingFile', } render(props) screen.getByText('Sending software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 20%') }) - it('should render text and progressbar - validating software', () => { + it('should render text - validating software', () => { props = { ...props, - processProgress: 80, updateType: 'validating', } render(props) screen.getByText('Validating software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 80%') }) - it('should render text and progressbar - installing software', () => { + it('should render text - installing software', () => { props = { ...props, - processProgress: 5, updateType: 'installing', } render(props) screen.getByText('Installing software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 5%') }) }) diff --git a/app/src/organisms/UpdateRobotSoftware/index.tsx b/app/src/organisms/UpdateRobotSoftware/index.tsx index c88f3197491..4d61272ac6f 100644 --- a/app/src/organisms/UpdateRobotSoftware/index.tsx +++ b/app/src/organisms/UpdateRobotSoftware/index.tsx @@ -37,7 +37,7 @@ export function UpdateRobotSoftware( const dispatch = useDispatch() const session = useSelector(getRobotUpdateSession) - const { step, stage, progress, error: sessionError } = session ?? { + const { step, stage, error: sessionError } = session ?? { step: null, error: null, } @@ -76,11 +76,6 @@ export function UpdateRobotSoftware( beforeCommittingSuccessfulUpdate && beforeCommittingSuccessfulUpdate() } } - return ( - - ) + return } } From 61b137132a3130e8cea170c7cf3d4c1931735942 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 9 Apr 2024 15:32:23 -0400 Subject: [PATCH 25/49] fix(app): display app version again (#14844) When we switched to vite, we had to switch all the stuff we'd been injecting at pack time via webpack environment/define plugins to vite's `define` config functionality. The biggest thing we specify that way is the app version, which is used across the stack for display and for logic. In the commit that switched to vite, we added that injection for the app-shell vite configs but did not add it for the app vite configs. That meant that at runtime, the version value was undefined, which breaks robot update notifications and causes the app version in the general settings tab to not display (it also makes the logo wrong on internal releases but that's a bit less important). The fix is to inject the version into the app build again. This is made a little more complicated because if you're doing stuff to the app vite config, it has to work in both the vite devserver and the vite offline packaging environments, and the vite devserver doesn't allow commonjs, and the git-version script that gives us the version is commonjs. For the purposes of vite's devserver, "doesn't work with cjs" actually just means "doesn't support require()", so you can use a hybrid syntax that uses import-statements but still module.export instead of export statements. Unfortunately, the git-version script is also used in the electron-builder config for the app-shell and the app-shell-odd, and the electron-builder config is run via node, and to import an ESM from a node CJS script - which electron-builder.config.js is - you need to change your import syntax to dynamic import and you need to make the import target explicitly (to node) an ESM, aka change its extension, and you need to use full ESM syntax including exports. This also goes for the create-release script. So that means that - git-version.js becomes git-version.mjs and uses full ESM syntax - that means that everywhere it's imported we need to import it by full path with extension instead of module name - also we need to import it dynamically in the electron config - oh and we need to actually add the define configs so we get the version in the app And then finally we show the version again. Also, remove some old webpack.config.js files that aren't used anymore. Closes EXEC-385 --- app-shell-odd/vite.config.ts | 5 +- app-shell/electron-builder.config.js | 5 +- app-shell/vite.config.ts | 5 +- app/vite.config.ts | 109 +++++++++++--------- components/webpack.config.js | 38 ------- discovery-client/vite.config.ts | 7 +- discovery-client/webpack.config.js | 26 ----- labware-designer/webpack.config.js | 26 ----- labware-library/webpack.config.js | 67 ------------ protocol-designer/vite.config.ts | 5 +- scripts/deploy/create-release.js | 55 +++++++--- scripts/{git-version.js => git-version.mjs} | 28 ++--- scripts/update-releases-json.js | 2 +- shared-data/webpack.config.js | 20 ---- usb-bridge/node-client/webpack.config.js | 26 ----- 15 files changed, 124 insertions(+), 300 deletions(-) delete mode 100644 components/webpack.config.js delete mode 100644 discovery-client/webpack.config.js delete mode 100644 labware-designer/webpack.config.js delete mode 100644 labware-library/webpack.config.js rename scripts/{git-version.js => git-version.mjs} (79%) delete mode 100644 shared-data/webpack.config.js delete mode 100644 usb-bridge/node-client/webpack.config.js diff --git a/app-shell-odd/vite.config.ts b/app-shell-odd/vite.config.ts index b9575159675..a3b8351fee6 100644 --- a/app-shell-odd/vite.config.ts +++ b/app-shell-odd/vite.config.ts @@ -1,13 +1,14 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' +import type {UserConfig} from 'vite' export default defineConfig( async (): Promise => { diff --git a/app-shell/electron-builder.config.js b/app-shell/electron-builder.config.js index 727b2d5e900..aa61720338b 100644 --- a/app-shell/electron-builder.config.js +++ b/app-shell/electron-builder.config.js @@ -1,6 +1,5 @@ 'use strict' const path = require('path') -const { versionForProject } = require('../scripts/git-version') const { OT_APP_DEPLOY_BUCKET, @@ -45,7 +44,9 @@ module.exports = async () => ({ }, ], extraMetadata: { - version: await versionForProject(project), + version: await ( + await import('../scripts/git-version.mjs') + ).versionForProject(project), productName: project === 'robot-stack' ? 'Opentrons' : 'Opentrons-OT3', }, extraResources: USE_PYTHON ? ['python'] : [], diff --git a/app-shell/vite.config.ts b/app-shell/vite.config.ts index 80ca80b0aa4..546fe19e23f 100644 --- a/app-shell/vite.config.ts +++ b/app-shell/vite.config.ts @@ -1,7 +1,8 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' +import type { UserConfig } from 'vite' export default defineConfig( async (): Promise => { diff --git a/app/vite.config.ts b/app/vite.config.ts index 9710acdd240..f88d492056a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -6,57 +6,66 @@ import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' +import { versionForProject } from '../scripts/git-version.mjs' +import type { UserConfig } from 'vite' -export default defineConfig({ - // this makes imports relative rather than absolute - base: '', - build: { - // Relative to the root - outDir: 'dist', - }, - plugins: [ - react({ - include: '**/*.tsx', - babel: { - // Use babel.config.js files - configFile: true, +export default defineConfig( + async(): Promise => { + const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' + const version = await versionForProject(project) + return { + // this makes imports relative rather than absolute + base: '', + build: { + // Relative to the root + outDir: 'dist', }, - }), - ], - optimizeDeps: { - esbuildOptions: { - target: 'es2020', - }, - }, - css: { - postcss: { plugins: [ - postCssImport({ root: 'src/' }), - postCssApply(), - postColorModFunction(), - postCssPresetEnv({ stage: 0 }), - lostCss(), + react({ + include: '**/*.tsx', + babel: { + // Use babel.config.js files + configFile: true, + }, + }), ], - }, - }, - define: { - 'process.env': process.env, - global: 'globalThis', - }, - resolve: { - alias: { - '@opentrons/components/styles': path.resolve( - '../components/src/index.module.css' - ), - '@opentrons/components': path.resolve('../components/src/index.ts'), - '@opentrons/shared-data': path.resolve('../shared-data/js/index.ts'), - '@opentrons/step-generation': path.resolve( - '../step-generation/src/index.ts' - ), - '@opentrons/api-client': path.resolve('../api-client/src/index.ts'), - '@opentrons/react-api-client': path.resolve( - '../react-api-client/src/index.ts' - ), - }, - }, -}) + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + }, + }, + css: { + postcss: { + plugins: [ + postCssImport({ root: 'src/' }), + postCssApply(), + postColorModFunction(), + postCssPresetEnv({ stage: 0 }), + lostCss(), + ], + }, + }, + define: { + 'process.env': process.env, + global: 'globalThis', + _PKG_VERSION_: JSON.stringify(version), + _OPENTRONS_PROJECT_: JSON.stringify(project), + }, + resolve: { + alias: { + '@opentrons/components/styles': path.resolve( + '../components/src/index.module.css' + ), + '@opentrons/components': path.resolve('../components/src/index.ts'), + '@opentrons/shared-data': path.resolve('../shared-data/js/index.ts'), + '@opentrons/step-generation': path.resolve( + '../step-generation/src/index.ts' + ), + '@opentrons/api-client': path.resolve('../api-client/src/index.ts'), + '@opentrons/react-api-client': path.resolve( + '../react-api-client/src/index.ts' + ), + }, + }, + } + }) diff --git a/components/webpack.config.js b/components/webpack.config.js deleted file mode 100644 index 648eaee3432..00000000000 --- a/components/webpack.config.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict' - -const path = require('path') -const { rules } = require('@opentrons/webpack-config') - -const ENTRY_INDEX = path.join(__dirname, 'src/barrel.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') - -module.exports = { - target: 'web', - entry: { index: ENTRY_INDEX }, - output: { - path: OUTPUT_PATH, - filename: 'opentrons-components.js', - library: '@opentrons/components', - libraryTarget: 'umd', - globalObject: 'this', - }, - mode: 'production', - module: { rules: [rules.js] }, - resolve: { - extensions: ['.wasm', '.mjs', '.js', '.ts', '.tsx', '.json'], - }, - externals: { - react: { - root: 'React', - commonjs2: 'react', - commonjs: 'react', - amd: 'react', - }, - 'react-dom': { - root: 'ReactDOM', - commonjs2: 'react-dom', - commonjs: 'react-dom', - amd: 'react-dom', - }, - }, -} diff --git a/discovery-client/vite.config.ts b/discovery-client/vite.config.ts index 7cbd9ae43c3..203012d904a 100644 --- a/discovery-client/vite.config.ts +++ b/discovery-client/vite.config.ts @@ -1,14 +1,15 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' - +import type { UserConfig } from 'vite +' export default defineConfig( async (): Promise => { const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' diff --git a/discovery-client/webpack.config.js b/discovery-client/webpack.config.js deleted file mode 100644 index c15a3bae1c2..00000000000 --- a/discovery-client/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { DefinePlugin } = require('webpack') -const { nodeBaseConfig } = require('@opentrons/webpack-config') -const { versionForProject } = require('../scripts/git-version') - -const ENTRY_INDEX = path.join(__dirname, 'src/index.ts') -const ENTRY_CLI = path.join(__dirname, 'src/cli.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') -const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' - -module.exports = async () => - webpackMerge(nodeBaseConfig, { - entry: { - index: ENTRY_INDEX, - cli: ENTRY_CLI, - }, - output: { path: OUTPUT_PATH }, - plugins: [ - new DefinePlugin({ - _PKG_VERSION_: JSON.stringify(await versionForProject(project)), - }), - ], - }) diff --git a/labware-designer/webpack.config.js b/labware-designer/webpack.config.js deleted file mode 100644 index aec3b7cc0cb..00000000000 --- a/labware-designer/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const HtmlWebpackPlugin = require('html-webpack-plugin') -const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin') - -const { baseConfig } = require('@opentrons/webpack-config') -const { productName: title, description, author } = require('./package.json') - -const JS_ENTRY = path.join(__dirname, 'src/index.tsx') -const HTML_ENTRY = path.join(__dirname, 'src/index.hbs') -const OUTPUT_PATH = path.join(__dirname, 'dist') - -module.exports = webpackMerge(baseConfig, { - entry: [JS_ENTRY], - - output: { - path: OUTPUT_PATH, - }, - - plugins: [ - new HtmlWebpackPlugin({ title, description, author, template: HTML_ENTRY }), - new ScriptExtHtmlWebpackPlugin({ defaultAttribute: 'defer' }), - ], -}) diff --git a/labware-library/webpack.config.js b/labware-library/webpack.config.js deleted file mode 100644 index c5fb0d8c7e8..00000000000 --- a/labware-library/webpack.config.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict' -const path = require('path') -const webpack = require('webpack') -const merge = require('webpack-merge') -const HtmlWebpackPlugin = require('html-webpack-plugin') -// const glob = require('glob') - -const { baseConfig } = require('@opentrons/webpack-config') -// const {baseConfig, DEV_MODE} = require('@opentrons/webpack-config') -const pkg = require('./package.json') - -const { versionForProject } = require('../scripts/git-version') - -const JS_ENTRY = path.join(__dirname, 'src/index.tsx') -const HTML_ENTRY = path.join(__dirname, 'src/index.hbs') -const OUT_PATH = path.join(__dirname, 'dist') - -const LABWARE_LIBRARY_ENV_VAR_PREFIX = 'OT_LL' - -const passThruEnvVars = Object.keys(process.env) - .filter(v => v.startsWith(LABWARE_LIBRARY_ENV_VAR_PREFIX)) - .concat(['NODE_ENV', 'CYPRESS']) - -const testAliases = - process.env.CYPRESS === '1' - ? { - 'file-saver': path.resolve(__dirname, 'cypress/mocks/file-saver.js'), - } - : {} - -module.exports = async () => { - const envVarsWithDefaults = { - OT_LL_VERSION: await versionForProject('labware-library'), - OT_LL_BUILD_DATE: new Date().toUTCString(), - } - - const envVars = passThruEnvVars.reduce( - (acc, envVar) => ({ [envVar]: '', ...acc }), - { ...envVarsWithDefaults } - ) - - return merge(baseConfig, { - entry: JS_ENTRY, - - output: { - path: OUT_PATH, - publicPath: '/', - }, - - plugins: [ - new webpack.EnvironmentPlugin(envVars), - - new HtmlWebpackPlugin({ - template: HTML_ENTRY, - title: pkg.productName, - description: pkg.description, - author: pkg.author.name, - gtmId: process.env.GTM_ID, - favicon: './src/images/favicon.ico', - }), - ], - - resolve: { - alias: testAliases, - }, - }) -} diff --git a/protocol-designer/vite.config.ts b/protocol-designer/vite.config.ts index 70d055a6fd8..7f7b8dd680d 100644 --- a/protocol-designer/vite.config.ts +++ b/protocol-designer/vite.config.ts @@ -1,12 +1,13 @@ import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' +import type { UserConfig } from 'vite' const testAliases: {} | { 'file-saver': string } = process.env.CYPRESS === '1' diff --git a/scripts/deploy/create-release.js b/scripts/deploy/create-release.js index eb4db62bd2a..3b804506a2e 100644 --- a/scripts/deploy/create-release.js +++ b/scripts/deploy/create-release.js @@ -22,12 +22,6 @@ const parseArgs = require('./lib/parseArgs') const conventionalChangelog = require('conventional-changelog') const semver = require('semver') const { Octokit } = require('@octokit/rest') -const { - detailsFromTag, - tagFromDetails, - prefixForProject, - monorepoGit, -} = require('../git-version') const USAGE = '\nUsage:\n node ./scripts/deploy/create-release [--deploy] [--allow-old]' @@ -81,9 +75,35 @@ function versionPrevious(currentVersion, previousVersions) { return releasesOfGEQKind.length === 0 ? null : releasesOfGEQKind[0] } +async function gitVersion() { + let imported + if (imported === undefined) { + imported = await import('../git-version.mjs') + } + return imported +} + +async function monorepoGit() { + return await (await gitVersion()).monorepoGit() +} + +async function detailsFromTag(tag) { + return await (await gitVersion()).detailsFromTag(tag) +} + +async function tagFromDetails(project, version) { + return (await gitVersion()).tagFromDetails(project, version) +} + +async function prefixForProject(project) { + return (await gitVersion()).prefixForProject(project) +} + async function versionDetailsFromGit(tag, allowOld) { if (!allowOld) { - const last100 = await monorepoGit().log({ from: 'HEAD~100', to: 'HEAD' }) + const git = await monorepoGit() + const last100 = await git.log({ from: 'HEAD~100', to: 'HEAD' }) + if (!last100.all.some(commit => commit.refs.includes('tag: ' + tag))) { throw new Error( `Cannot find tag ${tag} in last 100 commits. You must run this script from a ref with ` + @@ -94,9 +114,8 @@ async function versionDetailsFromGit(tag, allowOld) { } const [project, currentVersion] = detailsFromTag(tag) - - const allTags = (await monorepoGit().tags([prefixForProject(project) + '*'])) - .all + const prefix = await prefixForProject(project) + const allTags = (await monorepoGit().tags([prefix + '*'])).all if (!allTags.includes(tag)) { throw new Error( `Tag ${tag} does not exist - create it before running this script` @@ -123,14 +142,15 @@ async function buildChangelog(project, currentVersion, previousVersion) { `## ${currentVersion}` + `\nFirst release for ${titleForProject(project)}` ) } - const previousTag = tagFromDetails(project, previousVersion) - + const previousTag = await tagFromDetails(project, previousVersion) + const currentTag = await tagFromDetails(project, currentVersion) + const prefix = await prefixForProject(Project) const changelogStream = conventionalChangelog( - { preset: 'angular', tagPrefix: prefixForProject(project) }, + { preset: 'angular', tagPrefix: prefix }, { version: currentVersion, - currentTag: tagFromDetails(project, currentVersion), - previousTag: previousTag, + currentTag, + previousTag, host: 'https://github.com', owner: REPO_DETAILS.owner, repository: REPO_DETAILS.repo, @@ -203,6 +223,7 @@ async function main() { currentVersion, previousVersion, ] = await versionDetailsFromGit(tag, allowOld) + const prefix = await prefixForProject(project) const changelog = await buildChangelog( project, currentVersion, @@ -211,8 +232,8 @@ async function main() { const truncatedChangelog = truncateAndAnnotate( changelog, 10000, - prefixForProject(project) + previousVersion, - prefixForProject(project) + currentVersion + prefix + previousVersion, + prefix + currentVersion ) return await createRelease( token, diff --git a/scripts/git-version.js b/scripts/git-version.mjs similarity index 79% rename from scripts/git-version.js rename to scripts/git-version.mjs index a2dab912f23..7b4d364d0da 100644 --- a/scripts/git-version.js +++ b/scripts/git-version.mjs @@ -15,23 +15,24 @@ // What that all boils down to is that we need, and this module provides, an interface to get the version of a // given project that currently exists in the monorepo. -const git = require('simple-git') -const { dirname } = require('path') -const REPO_BASE = dirname(__dirname) +import git from 'simple-git' +import { dirname } from 'path' +import { fileURLToPath } from 'url' +const REPO_BASE = dirname(dirname(fileURLToPath(import.meta.url))) -function monorepoGit() { +export function monorepoGit() { return git({ baseDir: REPO_BASE }) } -const detailsFromTag = tag => +export const detailsFromTag = tag => tag.includes('@') ? tag.split('@') : ['robot-stack', tag.substring(1)] -function tagFromDetails(project, version) { +export function tagFromDetails(project, version) { const prefix = prefixForProject(project) return `${prefix}${version}` } -function prefixForProject(project) { +export function prefixForProject(project) { if (project === 'robot-stack') { return 'v' } else { @@ -39,7 +40,7 @@ function prefixForProject(project) { } } -async function latestTagForProject(project) { +export async function latestTagForProject(project) { return ( await monorepoGit().raw([ 'describe', @@ -50,7 +51,7 @@ async function latestTagForProject(project) { ).trim() } -async function versionForProject(project) { +export async function versionForProject(project) { return latestTagForProject(project) .then(tag => detailsFromTag(tag)[1]) .catch(error => { @@ -60,12 +61,3 @@ async function versionForProject(project) { return '0.0.0-dev' }) } - -module.exports = { - detailsFromTag, - tagFromDetails, - prefixForProject, - latestTagForProject, - versionForProject, - monorepoGit, -} diff --git a/scripts/update-releases-json.js b/scripts/update-releases-json.js index 3286256c42b..0e529d5447e 100644 --- a/scripts/update-releases-json.js +++ b/scripts/update-releases-json.js @@ -4,7 +4,7 @@ const fs = require('fs/promises') // Updates a releases historical manifest with a release's version. -const versionFinder = require('./git-version') +const versionFinder = require('./git-version.mjs') const parseArgs = require('./deploy/lib/parseArgs') const USAGE = diff --git a/shared-data/webpack.config.js b/shared-data/webpack.config.js deleted file mode 100644 index 18aa6478319..00000000000 --- a/shared-data/webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { baseConfig } = require('@opentrons/webpack-config') - -const ENTRY_INDEX = path.join(__dirname, 'js/index.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') - -module.exports = async () => - webpackMerge(baseConfig, { - entry: { index: ENTRY_INDEX }, - output: { - path: OUTPUT_PATH, - filename: 'opentrons-shared-data.js', - library: '@opentrons/shared-data', - libraryTarget: 'umd', - globalObject: 'this', - }, - }) diff --git a/usb-bridge/node-client/webpack.config.js b/usb-bridge/node-client/webpack.config.js deleted file mode 100644 index c01e57beb07..00000000000 --- a/usb-bridge/node-client/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { DefinePlugin } = require('webpack') -const { nodeBaseConfig } = require('@opentrons/webpack-config') -const { versionForProject } = require('../../scripts/git-version') - -const ENTRY_INDEX = path.join(__dirname, 'src/index.ts') -const ENTRY_CLI = path.join(__dirname, 'src/cli.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') -const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' - -module.exports = async () => - webpackMerge(nodeBaseConfig, { - entry: { - index: ENTRY_INDEX, - cli: ENTRY_CLI, - }, - output: { path: OUTPUT_PATH }, - plugins: [ - new DefinePlugin({ - _PKG_VERSION_: JSON.stringify(await versionForProject(project)), - }), - ], - }) From 476149e748bccbf2c75fbdc5120554595042fc98 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 9 Apr 2024 17:12:35 -0400 Subject: [PATCH 26/49] feat(protocol-designer): create container for all tipracks (#14848) closes AUTH-313 --- .../StepEditForm/fields/TiprackField.tsx | 72 +++++++++++++------ .../fields/__tests__/TiprackField.test.tsx | 60 ++++++++++++++++ .../components/StepEditForm/forms/MixForm.tsx | 5 +- .../forms/MoveLiquidForm/index.tsx | 5 +- .../modals/CreateFileWizard/index.tsx | 8 +-- .../src/localization/en/tooltip.json | 1 + protocol-designer/src/ui/labware/selectors.ts | 14 ++-- 7 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx diff --git a/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx b/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx index a9dceb482a2..464b15b4f7f 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx @@ -1,32 +1,62 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { FormGroup, DropdownField } from '@opentrons/components' +import { + FormGroup, + DropdownField, + useHoverTooltip, + Tooltip, + Box, +} from '@opentrons/components' import { selectors as uiLabwareSelectors } from '../../../ui/labware' -import styles from '../StepEditForm.module.css' - +import { getPipetteEntities } from '../../../step-forms/selectors' import type { FieldProps } from '../types' -export function TiprackField(props: FieldProps): JSX.Element { - const { name, value, onFieldBlur, onFieldFocus, updateValue } = props - const { t } = useTranslation('form') +import styles from '../StepEditForm.module.css' + +interface TiprackFieldProps extends FieldProps { + pipetteId?: unknown +} +export function TiprackField(props: TiprackFieldProps): JSX.Element { + const { + name, + value, + onFieldBlur, + onFieldFocus, + updateValue, + pipetteId, + } = props + const { t } = useTranslation(['form', 'tooltip']) + const [targetProps, tooltipProps] = useHoverTooltip() + const pipetteEntities = useSelector(getPipetteEntities) const options = useSelector(uiLabwareSelectors.getTiprackOptions) + const defaultTipracks = + pipetteId != null ? pipetteEntities[pipetteId as string].tiprackDefURI : [] + const pipetteOptions = options.filter(option => + defaultTipracks.includes(option.defURI) + ) + const hasMissingTiprack = defaultTipracks.length > pipetteOptions.length return ( - - ) => { - updateValue(e.currentTarget.value) - }} - /> - + + + ) => { + updateValue(e.currentTarget.value) + }} + /> + + {hasMissingTiprack ? ( + {t('tooltip:missing_tiprack')} + ) : null} + ) } diff --git a/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx new file mode 100644 index 00000000000..979155a4d88 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import { i18n } from '../../../../localization' +import { getPipetteEntities } from '../../../../step-forms/selectors' +import { renderWithProviders } from '../../../../__testing-utils__' +import { getTiprackOptions } from '../../../../ui/labware/selectors' +import { TiprackField } from '../TiprackField' + +vi.mock('../../../../ui/labware/selectors') +vi.mock('../../../../step-forms/selectors') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockMockId = 'mockId' +describe('TiprackField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + disabled: false, + value: null, + name: 'tipRackt', + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + pipetteId: mockMockId, + } + vi.mocked(getPipetteEntities).mockReturnValue({ + [mockMockId]: { + name: 'p50_single_flex', + spec: {} as any, + id: mockMockId, + tiprackLabwareDef: [], + tiprackDefURI: ['mockDefURI1', 'mockDefURI2'], + }, + }) + vi.mocked(getTiprackOptions).mockReturnValue([ + { + value: 'mockValue', + name: 'tiprack1', + defURI: 'mockDefURI1', + }, + { + value: 'mockValue', + name: 'tiprack2', + defURI: 'mockDefURI2', + }, + ]) + }) + it('renders the dropdown field and texts', () => { + render(props) + screen.getByText('tip rack') + screen.getByText('tiprack1') + screen.getByText('tiprack2') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx index ef1b408cfe4..f0eb043b081 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx @@ -52,7 +52,10 @@ export const MixForm = (props: StepFormProps): JSX.Element => {
    - + {is96Channel ? ( ) : null} diff --git a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx index 67bd45ec663..66b8f1e34c2 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx @@ -42,7 +42,10 @@ export const MoveLiquidForm = (props: StepFormProps): JSX.Element => {
    - + {is96Channel ? ( ) : null} diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index eea2264199a..b19ab426f65 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -240,15 +240,15 @@ export function CreateFileWizard(): JSX.Element | null { const newTiprackModels: string[] = uniq( pipettes.flatMap(pipette => pipette.tiprackDefURI) ) + const FLEX_MIDDLE_SLOTS = ['C2', 'B2', 'A2'] + const OT2_MIDDLE_SLOTS = ['2', '5', '8', '11'] newTiprackModels.forEach((tiprackDefURI, index) => { - const ot2Slots = index === 0 ? '2' : '5' - const flexSlots = index === 0 ? 'C2' : 'B2' dispatch( labwareIngredActions.createContainer({ slot: values.fields.robotType === FLEX_ROBOT_TYPE - ? flexSlots - : ot2Slots, + ? FLEX_MIDDLE_SLOTS[index] + : OT2_MIDDLE_SLOTS[index], labwareDefURI: tiprackDefURI, adapterUnderLabwareDefURI: values.pipettesByMount.left.pipetteName === 'p1000_96' diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 7ef580d81ce..8e293d8efdd 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -8,6 +8,7 @@ "disabled_you_can_add_one_type": "Only one module of each type is allowed on the deck at a time", "not_enough_space_for_temp": "There is not enough space on the deck to add more temperature modules", "not_in_beta": "ⓘ Coming Soon", + "missing_tiprack": "Missing a tiprack? Make sure it is added to the deck", "step_description": { "heaterShaker": "Set heat, shake, or labware latch commands for the Heater-Shaker module", diff --git a/protocol-designer/src/ui/labware/selectors.ts b/protocol-designer/src/ui/labware/selectors.ts index dd4be8f0c62..27b3ea9f3ae 100644 --- a/protocol-designer/src/ui/labware/selectors.ts +++ b/protocol-designer/src/ui/labware/selectors.ts @@ -241,17 +241,22 @@ export const getDisposalOptions = createSelector( } ) -export const getTiprackOptions: Selector = createSelector( +export interface TiprackOption { + name: string + value: string + defURI: string +} +export const getTiprackOptions: Selector = createSelector( stepFormSelectors.getLabwareEntities, getLabwareNicknamesById, (labwareEntities, nicknamesById) => { const options = reduce( labwareEntities, ( - acc: Options, + acc: TiprackOption[], labwareEntity: LabwareEntity, labwareId: string - ): Options => { + ): TiprackOption[] => { const labwareDefURI = labwareEntity.labwareDefURI const optionValues = acc.map(option => option.value) @@ -266,12 +271,13 @@ export const getTiprackOptions: Selector = createSelector( { name: nicknamesById[labwareId], value: labwareId, + defURI: labwareDefURI, }, ] } }, [] ) - return _sortLabwareDropdownOptions(options) + return options } ) From 57a8152a1abaa0fc3ecce4153d6aa90fd963e5a4 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 9 Apr 2024 17:41:18 -0400 Subject: [PATCH 27/49] =?UTF-8?q?refactor(protocol-designer,=20components)?= =?UTF-8?q?:=20infoItem=20to=20nicely=20accommoda=E2=80=A6=20(#14850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …te multiple tipracks closes AUTH-314 --- components/src/instrument/InfoItem.tsx | 24 ---- components/src/instrument/InstrumentInfo.tsx | 110 +++++++++++------- .../__tests__/InstrumentInfo.test.tsx | 54 +++++++++ components/src/instrument/index.ts | 1 - .../src/step-forms/selectors/index.ts | 1 - 5 files changed, 122 insertions(+), 68 deletions(-) delete mode 100644 components/src/instrument/InfoItem.tsx create mode 100644 components/src/instrument/__tests__/InstrumentInfo.test.tsx diff --git a/components/src/instrument/InfoItem.tsx b/components/src/instrument/InfoItem.tsx deleted file mode 100644 index 82b5a491a37..00000000000 --- a/components/src/instrument/InfoItem.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react' - -import styles from './instrument.module.css' - -export interface InfoItemProps { - title: string | null - value: string - className?: string -} - -/** - * Used by `InstrumentInfo` for its titled values. - * But if you're using this, you probably want `LabeledValue` instead. - */ -export function InfoItem(props: InfoItemProps): JSX.Element { - const { title, value, className } = props - - return ( -
    - {title != null ?

    {title}

    : null} - {value} -
    - ) -} diff --git a/components/src/instrument/InstrumentInfo.tsx b/components/src/instrument/InstrumentInfo.tsx index d5d26a3b4b4..57ff12e0ed4 100644 --- a/components/src/instrument/InstrumentInfo.tsx +++ b/components/src/instrument/InstrumentInfo.tsx @@ -1,77 +1,103 @@ import * as React from 'react' import { LEFT, RIGHT } from '@opentrons/shared-data' -import { InfoItem } from './InfoItem' -import { InstrumentDiagram } from './InstrumentDiagram' -import styles from './instrument.module.css' import { Flex } from '../primitives' -import { SPACING } from '../ui-style-constants' +import { SPACING, TYPOGRAPHY } from '../ui-style-constants' +import { StyledText } from '../atoms' import { DIRECTION_COLUMN, JUSTIFY_CENTER } from '../styles' +import { InstrumentDiagram } from './InstrumentDiagram' import type { Mount } from '../robot-types' import type { InstrumentDiagramProps } from './InstrumentDiagram' +import styles from './instrument.module.css' + export interface InstrumentInfoProps { /** 'left' or 'right' */ mount: Mount - /** if true, show labels 'LEFT PIPETTE' / 'RIGHT PIPETTE' */ - showMountLabel?: boolean | null /** human-readable description, eg 'p300 Single-channel' */ description: string - /** paired tiprack models */ - tiprackModels?: string[] - /** if disabled, pipette & its info are grayed out */ - isDisabled: boolean /** specs of mounted pipette */ pipetteSpecs?: InstrumentDiagramProps['pipetteSpecs'] | null - /** classes to apply */ - className?: string - /** classes to apply to the info group child */ - infoClassName?: string + /** paired tiprack models */ + tiprackModels?: string[] /** children to display under the info */ children?: React.ReactNode + /** if true, show labels 'LEFT PIPETTE' / 'RIGHT PIPETTE' */ + showMountLabel?: boolean | null } +const MAX_WIDTH = '14rem' + export function InstrumentInfo(props: InstrumentInfoProps): JSX.Element { - const has96Channel = props.pipetteSpecs?.channels === 96 + const { + mount, + showMountLabel, + description, + tiprackModels, + pipetteSpecs, + children, + } = props + + const has96Channel = pipetteSpecs?.channels === 96 return ( - {props.mount === RIGHT && props.pipetteSpecs && ( + {mount === RIGHT && pipetteSpecs ? ( - )} + ) : null} + {/* NOTE: the color is our legacy c-font-dark, which matches the other colors in this component **/} + + + + {showMountLabel && !has96Channel ? `${mount} pipette` : 'pipette'} + + + {description} + + - - - {props.tiprackModels != null - ? props.tiprackModels.map((model, index) => ( - - )) - : null} + + + {'Tip rack'} + +
      + {tiprackModels != null && tiprackModels.length > 0 ? ( + tiprackModels.map((model, index) => ( +
    • + + {model} + +
    • + )) + ) : ( + + {'None'} + + )} +
    +
    - {props.children} - {props.mount === LEFT && props.pipetteSpecs && ( + {children} + {mount === LEFT && pipetteSpecs ? ( - )} + ) : null}
    ) } diff --git a/components/src/instrument/__tests__/InstrumentInfo.test.tsx b/components/src/instrument/__tests__/InstrumentInfo.test.tsx new file mode 100644 index 00000000000..bf92c48d4cb --- /dev/null +++ b/components/src/instrument/__tests__/InstrumentInfo.test.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, beforeEach, it, vi } from 'vitest' +import { LEFT, RIGHT, fixtureP1000SingleV2Specs } from '@opentrons/shared-data' +import { renderWithProviders } from '../../testing/utils' +import { InstrumentInfo } from '../InstrumentInfo' +import { InstrumentDiagram } from '../InstrumentDiagram' + +vi.mock('../InstrumentDiagram') +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] +} + +describe('InstrumentInfo', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + mount: LEFT, + description: 'mock description', + pipetteSpecs: fixtureP1000SingleV2Specs, + tiprackModels: ['mock1', 'mock2'], + showMountLabel: true, + } + vi.mocked(InstrumentDiagram).mockReturnValue( +
    mock instrumentDiagram
    + ) + }) + it('renders a p1000 pipette with 2 tiprack models for left mount', () => { + render(props) + screen.getByText('mock instrumentDiagram') + screen.getByText('left pipette') + screen.getByText('mock description') + screen.getByText('Tip rack') + screen.getByText('mock1') + screen.getByText('mock2') + }) + it('renders a p1000 pipette with 1 tiprack model for right mount', () => { + props.mount = RIGHT + props.tiprackModels = ['mock1'] + render(props) + screen.getByText('mock instrumentDiagram') + screen.getByText('right pipette') + screen.getByText('mock description') + screen.getByText('Tip rack') + screen.getByText('mock1') + }) + it('renders none for pip and tiprack if none are selected', () => { + props.pipetteSpecs = undefined + props.tiprackModels = undefined + render(props) + screen.getByText('None') + }) +}) diff --git a/components/src/instrument/index.ts b/components/src/instrument/index.ts index 1153df43ae7..d566fb66e5b 100644 --- a/components/src/instrument/index.ts +++ b/components/src/instrument/index.ts @@ -1,4 +1,3 @@ -export * from './InfoItem' export * from './InstrumentDiagram' export * from './InstrumentGroup' export * from './InstrumentInfo' diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index a81846be991..1c0be8ca60c 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -406,7 +406,6 @@ export const getPipettesForInstrumentGroup: Selector< mount: pipetteOnDeck.mount, pipetteSpecs: pipetteSpec, description: _getPipetteDisplayName(pipetteOnDeck.name), - isDisabled: false, tiprackModels: tiprackDefs?.map((def: LabwareDefinition2) => getLabwareDisplayName(def) ), From f81da99b373b7a95fef8bbe866d85d234eeeef0b Mon Sep 17 00:00:00 2001 From: koji Date: Wed, 10 Apr 2024 08:40:30 -0400 Subject: [PATCH 28/49] fix(shared-data, app): fix small issues in app (#14851) * fix(shared-data, app): fix small issues in app --- .../NumericalKeyboard.stories.tsx | 1 - .../__tests__/HistoricalProtocolRun.test.tsx | 4 +- .../Devices/__tests__/RobotOverview.test.tsx | 4 +- .../ModuleCard/TemperatureModuleData.tsx | 2 - ...formatRunTimeParameterDefaultValue.test.ts | 145 ++++++++++++++++++ .../formatRunTimeParameterValue.test.ts | 16 +- .../formatRunTimeParameterDefaultValue.ts | 10 ++ .../helpers/formatRunTimeParameterMinMax.ts | 21 +++ .../js/helpers/formatRunTimeParameterValue.ts | 10 ++ .../orderRuntimeParameterRangeOptions.ts | 26 ++-- 10 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx index d7659866c6a..53b3d714c4c 100644 --- a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx @@ -37,7 +37,6 @@ type Story = StoryObj const Keyboard = (args): JSX.Element => { const { isDecimal, hasHyphen } = args - console.log(isDecimal, hasHyphen) const [showKeyboard, setShowKeyboard] = React.useState(false) const [value, setValue] = React.useState('') const keyboardRef = React.useRef(null) diff --git a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx index bc59f8cf884..dccbb3dfefc 100644 --- a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx +++ b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx @@ -18,8 +18,8 @@ vi.mock('../../../redux/protocol-storage') vi.mock('../../RunTimeControl/hooks') vi.mock('../HistoricalProtocolRunOverflowMenu') vi.mock('react-router-dom', async importOriginal => { - const reactRouterDom = importOriginal() - return await { + const reactRouterDom = await importOriginal() + return { ...reactRouterDom, useHistory: () => ({ push: mockPush } as any), } diff --git a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx index b02e5ce600a..66f6d18b7d0 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx @@ -51,8 +51,8 @@ import type { State } from '../../../redux/types' import type * as ReactApiClient from '@opentrons/react-api-client' vi.mock('@opentrons/react-api-client', async importOriginal => { - const actual = importOriginal() - return await { + const actual = await importOriginal() + return { ...actual, useAuthorization: vi.fn(), } diff --git a/app/src/organisms/ModuleCard/TemperatureModuleData.tsx b/app/src/organisms/ModuleCard/TemperatureModuleData.tsx index b6b70c3ae48..c595e4513b4 100644 --- a/app/src/organisms/ModuleCard/TemperatureModuleData.tsx +++ b/app/src/organisms/ModuleCard/TemperatureModuleData.tsx @@ -29,8 +29,6 @@ export const TemperatureModuleData = ( let pulse switch (moduleStatus) { case 'idle': { - backgroundColor = COLORS.grey30 - iconColor = COLORS.grey60 textColor = COLORS.grey60 break } diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts new file mode 100644 index 00000000000..d83239e3ec9 --- /dev/null +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest' +import { formatRunTimeParameterDefaultValue } from '../formatRunTimeParameterDefaultValue' + +import type { RunTimeParameter } from '../../types' + +const capitalizeFirstLetter = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +const mockTFunction = vi.fn(str => capitalizeFirstLetter(str)) + +describe('formatRunTimeParameterDefaultValue', () => { + it('should return value with suffix when type is int', () => { + const mockData = { + value: 6, + displayName: 'PCR Cycles', + variableName: 'PCR_CYCLES', + description: 'number of PCR cycles on a thermocycler', + type: 'int', + min: 1, + max: 10, + default: 6, + suffix: 'samples', + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('6 samples') + }) + + it('should return value with suffix when type is float', () => { + const mockData = { + value: 6.5, + displayName: 'EtoH Volume', + variableName: 'ETOH_VOLUME', + description: '70% ethanol volume', + type: 'float', + suffix: 'mL', + min: 1.5, + max: 10.0, + default: 6.5, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('6.5 mL') + }) + + it('should return value when type is str', () => { + const mockData = { + value: 'left', + displayName: 'pipette mount', + variableName: 'mount', + description: 'pipette mount', + type: 'str', + choices: [ + { + displayName: 'Left', + value: 'left', + }, + { + displayName: 'Right', + value: 'right', + }, + ], + default: 'left', + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('Left') + }) + + it('should return value when type is int choice with suffix', () => { + const mockData = { + value: 5, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'int', + suffix: 'mL', + min: 1, + max: 10, + choices: [ + { + displayName: 'one', + value: 1, + }, + { + displayName: 'six', + value: 6, + }, + ], + default: 5, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is float choice with suffix', () => { + const mockData = { + value: 5.0, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'float', + suffix: 'mL', + min: 1.0, + max: 10.0, + choices: [ + { + displayName: 'one', + value: 1.0, + }, + { + displayName: 'six', + value: 6.0, + }, + ], + default: 5.0, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is boolean true', () => { + const mockData = { + value: true, + displayName: 'Deactivate Temperatures', + variableName: 'DEACTIVATE_TEMP', + description: 'deactivate temperature on the module', + type: 'bool', + default: true, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('On') + }) + + it('should return value when type is boolean false', () => { + const mockData = { + value: false, + displayName: 'Dry Run', + variableName: 'DRYRUN', + description: 'Is this a dry or wet run? Wet is true, dry is false', + type: 'bool', + default: false, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('Off') + }) +}) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts index 2f78d99e11c..8e228cb6dbc 100644 --- a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { formatRunTimeParameterDefaultValue } from '../formatRunTimeParameterDefaultValue' +import { formatRunTimeParameterValue } from '../formatRunTimeParameterValue' import type { RunTimeParameter } from '../../types' @@ -21,7 +21,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { max: 10, default: 6, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('6') }) @@ -37,7 +37,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { max: 10.0, default: 6.5, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('6.5 mL') }) @@ -60,7 +60,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { ], default: 'left', } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('Left') }) @@ -86,7 +86,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { ], default: 5, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('5 mL') }) @@ -112,7 +112,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { ], default: 5.0, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('5 mL') }) @@ -125,7 +125,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { type: 'bool', default: true, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('On') }) @@ -138,7 +138,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { type: 'bool', default: false, } as RunTimeParameter - const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('Off') }) }) diff --git a/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts index aa7d16a256f..3ac5cda5bfa 100644 --- a/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts @@ -1,5 +1,15 @@ import type { RunTimeParameter } from '../types' +/** + * Formats the runtime parameter's default value. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter whose default value is to be formatted. + * @param {Function} [t] - An optional function for localization. + * + * @returns {string} The formatted default value of the runtime parameter. + * + */ + export const formatRunTimeParameterDefaultValue = ( runTimeParameter: RunTimeParameter, t?: any diff --git a/shared-data/js/helpers/formatRunTimeParameterMinMax.ts b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts index 36444f89601..632dec5c020 100644 --- a/shared-data/js/helpers/formatRunTimeParameterMinMax.ts +++ b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts @@ -1,4 +1,25 @@ import type { RunTimeParameter } from '../types' +/** + * Formats the runtime parameter's minimum and maximum values. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter whose min and max values are to be formatted. + * + * @returns {string} The formatted min-max value of the runtime parameter. + * + * @example + * const runTimeParameter = { + * value: 6.5, + * displayName: 'EtoH Volume', + * variableName: 'ETOH_VOLUME', + * description: '70% ethanol volume', + * type: 'float', + * suffix: 'mL', + * min: 1.5, + * max: 10.0, + * default: 6.5, + * } + * console.log(formatRunTimeParameterMinMax(runTimeParameter)); // "1.5-10.0" + */ export const formatRunTimeParameterMinMax = ( runTimeParameter: RunTimeParameter diff --git a/shared-data/js/helpers/formatRunTimeParameterValue.ts b/shared-data/js/helpers/formatRunTimeParameterValue.ts index a75bee5fd68..a6a3ad4d7ec 100644 --- a/shared-data/js/helpers/formatRunTimeParameterValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterValue.ts @@ -1,5 +1,15 @@ import type { RunTimeParameter } from '../types' +/** + * Formats the runtime parameter value. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter to be formatted. + * @param {Function} t - A function for localization. + * + * @returns {string} The formatted runtime parameter value. + * + */ + export const formatRunTimeParameterValue = ( runTimeParameter: RunTimeParameter, t: any diff --git a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts index c372e992a2b..826fc958dd1 100644 --- a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts +++ b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts @@ -9,19 +9,19 @@ export const isNumeric = (str: string): boolean => { * @param {Choice[]} - The array of Choice * Choice is an object like {displayName: 'Single channel 50µL', value: 'flex_1channel_50' } * @returns {string} The ordered string with "," - * - * examples - * [ - { displayName: '20', value: 20 }, - { displayName: '16', value: 16 }, - ] - return 16, 20 - - [ - { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, - { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, - ] - return Eight Channel 50µL, Single channel 50µL + * + * @example + * const numChoices = [ + * { displayName: '20', value: 20 }, + * { displayName: '16', value: 16 }, + * ] + * console.log(orderRuntimeParameterRangeOptions(numChoices) // 16,20 + * + * const strChoices = [ + * { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, + * { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, + * ] + * console.log(orderRuntimeParameterRangeOptions(strChoices) // Eight Channel 50µL, Single channel 50µL */ export const orderRuntimeParameterRangeOptions = ( choices: Choice[] From 754417586779af5cfaab5107f5e8c40a3f4a8492 Mon Sep 17 00:00:00 2001 From: koji Date: Wed, 10 Apr 2024 12:35:08 -0400 Subject: [PATCH 29/49] fix(discovery-client): fix import statement (#14856) * fix(discovery-client): fix import statement --- discovery-client/vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discovery-client/vite.config.ts b/discovery-client/vite.config.ts index 203012d904a..c67977a8359 100644 --- a/discovery-client/vite.config.ts +++ b/discovery-client/vite.config.ts @@ -8,8 +8,8 @@ import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' -import type { UserConfig } from 'vite -' +import type { UserConfig } from 'vite' + export default defineConfig( async (): Promise => { const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' From e4233194900fc00a0441f52b248525294aa757e7 Mon Sep 17 00:00:00 2001 From: koji Date: Wed, 10 Apr 2024 12:43:04 -0400 Subject: [PATCH 30/49] feat(opentrons-ai-client, opentrons-ai-server): add folders for opentrons-ai (#14788) * feat(opentrons-ai-client, opentrons-ai-server): add folders for opentrons-ai --- opentrons-ai-client/Makefile | 59 +++++++++++++++++ opentrons-ai-client/README.md | 64 +++++++++++++++++++ opentrons-ai-client/babel.config.cjs | 21 ++++++ opentrons-ai-client/index.html | 13 ++++ opentrons-ai-client/package.json | 38 +++++++++++ opentrons-ai-client/src/App.test.tsx | 18 ++++++ opentrons-ai-client/src/App.tsx | 9 +++ .../src/__testing-utils__/index.ts | 2 + .../src/__testing-utils__/matchers.ts | 24 +++++++ .../__testing-utils__/renderWithProviders.tsx | 53 +++++++++++++++ .../src/assets/localization/en/index.ts | 7 ++ .../localization/en/protocol_generator.json | 23 +++++++ .../src/assets/localization/en/shared.json | 3 + .../src/assets/localization/index.ts | 5 ++ opentrons-ai-client/src/i18n.ts | 45 +++++++++++++ opentrons-ai-client/src/main.tsx | 14 ++++ opentrons-ai-client/tsconfig-data.json | 12 ++++ opentrons-ai-client/tsconfig.json | 16 +++++ opentrons-ai-client/typings/images.d.ts | 15 +++++ .../typings/styled-components.d.ts | 1 + opentrons-ai-client/vite.config.ts | 43 +++++++++++++ opentrons-ai-server/Makefile | 2 + opentrons-ai-server/README.md | 39 +++++++++++ tsconfig-eslint.json | 1 + 24 files changed, 527 insertions(+) create mode 100644 opentrons-ai-client/Makefile create mode 100644 opentrons-ai-client/README.md create mode 100644 opentrons-ai-client/babel.config.cjs create mode 100644 opentrons-ai-client/index.html create mode 100644 opentrons-ai-client/package.json create mode 100644 opentrons-ai-client/src/App.test.tsx create mode 100644 opentrons-ai-client/src/App.tsx create mode 100644 opentrons-ai-client/src/__testing-utils__/index.ts create mode 100644 opentrons-ai-client/src/__testing-utils__/matchers.ts create mode 100644 opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx create mode 100644 opentrons-ai-client/src/assets/localization/en/index.ts create mode 100644 opentrons-ai-client/src/assets/localization/en/protocol_generator.json create mode 100644 opentrons-ai-client/src/assets/localization/en/shared.json create mode 100644 opentrons-ai-client/src/assets/localization/index.ts create mode 100644 opentrons-ai-client/src/i18n.ts create mode 100644 opentrons-ai-client/src/main.tsx create mode 100644 opentrons-ai-client/tsconfig-data.json create mode 100644 opentrons-ai-client/tsconfig.json create mode 100644 opentrons-ai-client/typings/images.d.ts create mode 100644 opentrons-ai-client/typings/styled-components.d.ts create mode 100644 opentrons-ai-client/vite.config.ts create mode 100644 opentrons-ai-server/Makefile create mode 100644 opentrons-ai-server/README.md diff --git a/opentrons-ai-client/Makefile b/opentrons-ai-client/Makefile new file mode 100644 index 00000000000..9c15fa32e41 --- /dev/null +++ b/opentrons-ai-client/Makefile @@ -0,0 +1,59 @@ +# opentrons ai client makefile + +# using bash instead of /bin/bash in SHELL prevents macOS optimizing away our PATH update +SHELL := bash + +# add node_modules/.bin to PATH +PATH := $(shell cd .. && yarn bin):$(PATH) + +benchmark_output := $(shell node -e 'console.log(new Date());') + +# These variables can be overriden when make is invoked to customize the +# behavior of jest +tests ?= +cov_opts ?= --coverage=true +test_opts ?= + +# standard targets +##################################################################### + +.PHONY: all +all: clean build + +.PHONY: setup +setup: + yarn + +.PHONY: clean +clean: + shx rm -rf dist + +# artifacts +##################################################################### + +.PHONY: build +build: export NODE_ENV := production +build: + vite build + git rev-parse HEAD > dist/.commit + +# development +##################################################################### + +.PHONY: dev +dev: export NODE_ENV := development +dev: + vite serve + +# production assets server +.PHONY: serve +serve: all + node ../scripts/serve-static dist + +.PHONY: test +test: + $(MAKE) -C .. test-js-ai-client tests="$(tests)" test_opts="$(test_opts)" + +.PHONY: test-cov +test-cov: + make -C .. test-js-ai-client tests=$(tests) test_opts="$(test_opts)" cov_opts="$(cov_opts)" diff --git a/opentrons-ai-client/README.md b/opentrons-ai-client/README.md new file mode 100644 index 00000000000..c2ff2908418 --- /dev/null +++ b/opentrons-ai-client/README.md @@ -0,0 +1,64 @@ +# Opentrons AI Frontend + +[![JavaScript Style Guide][style-guide-badge]][style-guide] + +[Download][] | [Support][] + +## Overview + +The Opentrons AI application helps you to create a protocol with natural language. + +## Developing + +To get started: clone the `Opentrons/opentrons` repository, set up your computer for development as specified in the [contributing guide][contributing-guide-setup], and then: + +```shell +# change into the cloned directory +cd opentrons +# prerequisite: install dependencies as specified in project setup +make setup +# launch the dev server +make -C opentrons-ai-client dev +``` + +## Stack and structure + +The UI stack is built using: + +- [React][] +- [Babel][] +- [Vite][] + +Some important directories: + +- `opentrons-ai-server` — Opentrons AI application's server + +## Copy management + +We use [i18next](https://www.i18next.com) for copy management and internationalization. + +## Testing + +Tests for the Opentrons App are run from the top level along with all other JS project tests. + +- `make test-js` - Run all JavaScript tests + +Test tasks can also be run with the following arguments: + +| Argument | Default | Description | Example | +| -------- | -------- | ----------------------- | --------------------------------- | +| watch | `false` | Run tests in watch mode | `make test-unit watch=true` | +| cover | `!watch` | Calculate code coverage | `make test watch=true cover=true` | + +## Building + +TBD + +[style-guide]: https://standardjs.com +[style-guide-badge]: https://img.shields.io/badge/code_style-standard-brightgreen.svg?style=flat-square&maxAge=3600 +[contributing-guide-setup]: ../CONTRIBUTING.md#development-setup +[contributing-guide-running-the-api]: ../CONTRIBUTING.md#opentrons-api +[react]: https://react.dev/ +[babel]: https://babeljs.io/ +[vite]: https://vitejs.dev/ +[bundle-analyzer]: https://github.com/webpack-contrib/webpack-bundle-analyzer diff --git a/opentrons-ai-client/babel.config.cjs b/opentrons-ai-client/babel.config.cjs new file mode 100644 index 00000000000..11739e6bf00 --- /dev/null +++ b/opentrons-ai-client/babel.config.cjs @@ -0,0 +1,21 @@ +'use strict' + +module.exports = { + env: { + // Must have babel-plugin-styled-components in each env, + // see here for further details: s https://styled-components.com/docs/tooling#babel-plugin + production: { + plugins: ['babel-plugin-styled-components', 'babel-plugin-unassert'], + }, + development: { + plugins: ['babel-plugin-styled-components'], + }, + test: { + plugins: [ + // disable ssr, displayName to fix toHaveStyleRule + // https://github.com/styled-components/jest-styled-components/issues/294 + ['babel-plugin-styled-components', { ssr: false, displayName: false }], + ], + }, + }, +} diff --git a/opentrons-ai-client/index.html b/opentrons-ai-client/index.html new file mode 100644 index 00000000000..57e7f83f591 --- /dev/null +++ b/opentrons-ai-client/index.html @@ -0,0 +1,13 @@ + + + + + + + Opentrons AI + + +
    + + + diff --git a/opentrons-ai-client/package.json b/opentrons-ai-client/package.json new file mode 100644 index 00000000000..e3c056e8bfe --- /dev/null +++ b/opentrons-ai-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "opentrons-ai-client", + "type": "module", + "version": "0.0.0-dev", + "description": "Opentrons AI application UI", + "source": "src/index.tsx", + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/Opentrons/opentrons.git" + }, + "author": { + "name": "Opentrons Labworks", + "email": "engineering@opentrons.com" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/Opentrons/opentrons/issues" + }, + "homepage": "https://github.com/Opentrons/opentrons", + "dependencies": { + "@fontsource/dejavu-sans": "5.0.3", + "@fontsource/public-sans": "5.0.3", + "@opentrons/components": "link:../components", + "i18next": "^19.8.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "^4.0.10", + "react-i18next": "13.5.0", + "styled-components": "5.3.6" + }, + "engines": { + "node": ">=18.19.0" + }, + "devDependencies": { + "@types/styled-components": "^5.1.26" + } +} diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx new file mode 100644 index 00000000000..03b731311c0 --- /dev/null +++ b/opentrons-ai-client/src/App.test.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it } from 'vitest' + +import { renderWithProviders } from './__testing-utils__' + +import { App } from './App' + +const render = (): ReturnType => { + return renderWithProviders() +} + +describe('App', () => { + it('should render text', () => { + render() + screen.getByText('Opentrons AI') + }) +}) diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx new file mode 100644 index 00000000000..f31fbd35940 --- /dev/null +++ b/opentrons-ai-client/src/App.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { Flex, StyledText } from '@opentrons/components' +export function App(): JSX.Element { + return ( + + Opentrons AI + + ) +} diff --git a/opentrons-ai-client/src/__testing-utils__/index.ts b/opentrons-ai-client/src/__testing-utils__/index.ts new file mode 100644 index 00000000000..e17c0ffbc31 --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/index.ts @@ -0,0 +1,2 @@ +export * from './renderWithProviders' +export * from './matchers' diff --git a/opentrons-ai-client/src/__testing-utils__/matchers.ts b/opentrons-ai-client/src/__testing-utils__/matchers.ts new file mode 100644 index 00000000000..66234dbc915 --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/matchers.ts @@ -0,0 +1,24 @@ +import type { Matcher } from '@testing-library/react' + +// Match things like

    Some nested text

    +// Use with either string match: getByText(nestedTextMatcher("Some nested text")) +// or regexp: getByText(nestedTextMatcher(/Some nested text/)) +export const nestedTextMatcher = (textMatch: string | RegExp): Matcher => ( + content, + node +) => { + const hasText = (n: typeof node): boolean => { + if (n == null || n.textContent === null) return false + return typeof textMatch === 'string' + ? Boolean(n?.textContent.match(textMatch)) + : textMatch.test(n.textContent) + } + const nodeHasText = hasText(node) + const childrenDontHaveText = + node != null && Array.from(node.children).every(child => !hasText(child)) + + return nodeHasText && childrenDontHaveText +} + +// need componentPropsMatcher +// need partialComponentPropsMatcher diff --git a/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx new file mode 100644 index 00000000000..65a2e01855e --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx @@ -0,0 +1,53 @@ +// render using targetted component using @testing-library/react +// with wrapping providers for i18next and redux +import * as React from 'react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { I18nextProvider } from 'react-i18next' +import { Provider } from 'react-redux' +import { vi } from 'vitest' +import { render } from '@testing-library/react' +import { createStore } from 'redux' + +import type { PreloadedState, Store } from 'redux' +import type { RenderOptions, RenderResult } from '@testing-library/react' + +export interface RenderWithProvidersOptions extends RenderOptions { + initialState?: State + i18nInstance: React.ComponentProps['i18n'] +} + +export function renderWithProviders( + Component: React.ReactElement, + options?: RenderWithProvidersOptions +): [RenderResult, Store] { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { initialState = {}, i18nInstance = null } = options || {} + + const store: Store = createStore( + vi.fn(), + initialState as PreloadedState + ) + store.dispatch = vi.fn() + store.getState = vi.fn(() => initialState) as () => State + + const queryClient = new QueryClient() + + const ProviderWrapper: React.ComponentType> = ({ + children, + }) => { + const BaseWrapper = ( + + {children} + + ) + if (i18nInstance != null) { + return ( + {BaseWrapper} + ) + } else { + return BaseWrapper + } + } + + return [render(Component, { wrapper: ProviderWrapper }), store] +} diff --git a/opentrons-ai-client/src/assets/localization/en/index.ts b/opentrons-ai-client/src/assets/localization/en/index.ts new file mode 100644 index 00000000000..b5aa26621dd --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/index.ts @@ -0,0 +1,7 @@ +import shared from './shared.json' +import protocol_generator from './protocol_generator.json' + +export const en = { + shared, + protocol_generator, +} diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json new file mode 100644 index 00000000000..c8ac35504bb --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -0,0 +1,23 @@ +{ + "api": "API: An API level is 2.15", + "application": "Application: Your protocol's name, describing what it does.", + "commands": "Commands: List the protocol's steps, specifying quantities in microliters and giving exact source and destination locations.", + "make_sure_your_prompt": "Make sure your prompt includes the following:", + "metadata": "Metadata: Three pieces of information.", + "modules": "Modules: Thermocycler or Temperature Module.", + "opentronsai_asks_you": "OpentronsAI asks you to provide it!", + "ot2_pipettes": "OT-2 pipettes: Include volume, number of channels, and generation.", + "prc_flex": "PRC (Flex)", + "prc": "PCR", + "reagent_transfer_flex": "Reagent Transfer (Flex)", + "reagent_transfer": "Reagent Transfer", + "robot": "Robot: OT-2.", + "sidebar_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", + "sidebar_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", + "stuck": "Stuck? Try these example prompts to get started.", + "tipracks_and_labware": "Tip racks and labware: Use names from the Opentrons Labware Library.", + "type_your_prompt": "Type your prompt...", + "well_allocations": "Well allocations: Describe where liquids should go in labware.", + "what_if_you": "What if you don’t provide all of those pieces of information?", + "what_typeof_protocol": "What type of protocol do you need?" +} diff --git a/opentrons-ai-client/src/assets/localization/en/shared.json b/opentrons-ai-client/src/assets/localization/en/shared.json new file mode 100644 index 00000000000..46cb365873f --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/shared.json @@ -0,0 +1,3 @@ +{ + "send": "Send" +} diff --git a/opentrons-ai-client/src/assets/localization/index.ts b/opentrons-ai-client/src/assets/localization/index.ts new file mode 100644 index 00000000000..e92a7077ed9 --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/index.ts @@ -0,0 +1,5 @@ +import { en } from './en' + +export const resources = { + en, +} diff --git a/opentrons-ai-client/src/i18n.ts b/opentrons-ai-client/src/i18n.ts new file mode 100644 index 00000000000..0f7ef3bf6df --- /dev/null +++ b/opentrons-ai-client/src/i18n.ts @@ -0,0 +1,45 @@ +import i18n from 'i18next' +import capitalize from 'lodash/capitalize' +import startCase from 'lodash/startCase' +import { initReactI18next } from 'react-i18next' +import { resources } from './assets/localization' +import { titleCase } from '@opentrons/shared-data' + +i18n.use(initReactI18next).init( + { + resources, + lng: 'en', + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + ns: ['shared'], + defaultNS: 'shared', + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + format: function (value, format, lng) { + if (format === 'upperCase') return value.toUpperCase() + if (format === 'lowerCase') return value.toLowerCase() + if (format === 'capitalize') return capitalize(value) + if (format === 'sentenceCase') return startCase(value) + if (format === 'titleCase') return titleCase(value) + return value + }, + }, + keySeparator: false, // use namespaces and context instead + saveMissing: true, + missingKeyHandler: (lng, ns, key) => { + process.env.NODE_ENV === 'test' + ? console.error(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + : console.warn(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + }, + }, + err => { + if (err) { + console.error( + 'Internationalization was not initialized properly. error: ', + err + ) + } + } +) + +export { i18n } diff --git a/opentrons-ai-client/src/main.tsx b/opentrons-ai-client/src/main.tsx new file mode 100644 index 00000000000..466bd35e081 --- /dev/null +++ b/opentrons-ai-client/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' + +const rootElement = document.getElementById('root') +if (rootElement) { + ReactDOM.createRoot(rootElement).render( + + + + ) +} else { + console.error('Root element not found') +} diff --git a/opentrons-ai-client/tsconfig-data.json b/opentrons-ai-client/tsconfig-data.json new file mode 100644 index 00000000000..79a9673faa9 --- /dev/null +++ b/opentrons-ai-client/tsconfig-data.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig-base.json", + "references": [], + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": false, + "rootDir": ".", + "outDir": "lib" + }, + "include": ["src/**/*.json", "fixtures/**/*.json", "vite.config.ts"], + "exclude": ["**/*.ts", "**/*.tsx"] +} diff --git a/opentrons-ai-client/tsconfig.json b/opentrons-ai-client/tsconfig.json new file mode 100644 index 00000000000..b3c6dc275a8 --- /dev/null +++ b/opentrons-ai-client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig-base.json", + "references": [ + { + "path": "./tsconfig-data.json" + }, + { + "path": "../components" + } + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["typings", "src"] +} diff --git a/opentrons-ai-client/typings/images.d.ts b/opentrons-ai-client/typings/images.d.ts new file mode 100644 index 00000000000..9dcd2f68792 --- /dev/null +++ b/opentrons-ai-client/typings/images.d.ts @@ -0,0 +1,15 @@ +declare module '*.png' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} +declare module '*.svg' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} +declare module '*.webm' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} diff --git a/opentrons-ai-client/typings/styled-components.d.ts b/opentrons-ai-client/typings/styled-components.d.ts new file mode 100644 index 00000000000..5d6296f94be --- /dev/null +++ b/opentrons-ai-client/typings/styled-components.d.ts @@ -0,0 +1 @@ +import 'styled-components/cssprop' diff --git a/opentrons-ai-client/vite.config.ts b/opentrons-ai-client/vite.config.ts new file mode 100644 index 00000000000..ee557f68d62 --- /dev/null +++ b/opentrons-ai-client/vite.config.ts @@ -0,0 +1,43 @@ +import path from 'path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + // this makes imports relative rather than absolute + base: '', + build: { + // Relative to the root + outDir: 'dist', + }, + plugins: [ + react({ + include: '**/*.tsx', + babel: { + // Use babel.config.js files + configFile: true, + }, + }), + ], + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + }, + }, + css: { + postcss: { + plugins: [], + }, + }, + define: { + 'process.env': process.env, + global: 'globalThis', + }, + resolve: { + alias: { + '@opentrons/components/styles': path.resolve( + '../components/src/index.module.css' + ), + '@opentrons/components': path.resolve('../components/src/index.ts'), + }, + }, +}) diff --git a/opentrons-ai-server/Makefile b/opentrons-ai-server/Makefile new file mode 100644 index 00000000000..9de2141f6a0 --- /dev/null +++ b/opentrons-ai-server/Makefile @@ -0,0 +1,2 @@ +# opentrons ai server makefile +# TBD \ No newline at end of file diff --git a/opentrons-ai-server/README.md b/opentrons-ai-server/README.md new file mode 100644 index 00000000000..e00cdc1af3d --- /dev/null +++ b/opentrons-ai-server/README.md @@ -0,0 +1,39 @@ +# Opentrons AI Backend + +## Overview + +The Opentrons AI application's server. + +## Developing + +To get started: clone the `Opentrons/opentrons` repository, set up your computer for development as specified in the [contributing guide][contributing-guide-setup], and then: + +```shell +# change into the cloned directory +cd opentrons +# prerequisite: install dependencies as specified in project setup +make setup +# launch the dev server +make -C opentrons-ai-server dev +``` + +## Stack and structure + +The UI stack is built using: + +- [OpenAI Python API library][] + +Some important directories: + +- `opentrons-ai-client` — Opentrons AI application's client-side + +## Testing + +TBD + +## Building + +TBD + +[pytest]: https://docs.pytest.org/en/ +[openai python api library]: https://pypi.org/project/openai/ diff --git a/tsconfig-eslint.json b/tsconfig-eslint.json index 4468d4f6fd4..541feb786c0 100644 --- a/tsconfig-eslint.json +++ b/tsconfig-eslint.json @@ -19,6 +19,7 @@ "labware-designer/typings", "labware-library/src", "labware-library/typings", + "opentrons-ai-client/src", "shared-data/deck", "shared-data/js", "shared-data/protocol", From 8f50b081ed804153aa001ef85e27a8cc9dbc1449 Mon Sep 17 00:00:00 2001 From: Caila Marashaj <98041399+caila-marashaj@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:53:44 -0400 Subject: [PATCH 31/49] fix(api): ensure the right mount is enabled for initial homing (#14822) --- api/src/opentrons/hardware_control/ot3api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index e6ae891359b..24b613411c1 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1521,8 +1521,14 @@ async def _home_axis(self, axis: Axis) -> None: # G, Q should be handled in the backend through `self._home()` assert axis not in [Axis.G, Axis.Q] + # TODO(CM): This is a temporary fix in response to the right mount causing + # errors while trying to home on startup or attachment. We should remove this + # when we fix this issue in the firmware. + enable_right_mount_on_startup = ( + self._gantry_load == GantryLoad.HIGH_THROUGHPUT and axis == Axis.Z_R + ) encoder_ok = self._backend.check_encoder_status([axis]) - if encoder_ok: + if encoder_ok or enable_right_mount_on_startup: # enable motor (if needed) and update estimation await self._enable_before_update_estimation(axis) From a4bc70004fd25145a2b6b382665b4c40b51e9ecc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:07:21 -0500 Subject: [PATCH 32/49] fix(app-testing): snapshot failure capture (#14852) This PR is an automated snapshot update request. Please review the changes and merge if they are acceptable or find you bug and fix it. Co-authored-by: y3rsh --- ...sis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json | 2 +- ...t[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json | 2 +- ...ysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json index e52cb9863b1..d1786c8ca62 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json @@ -3293,7 +3293,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 442, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 207, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 257, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 503, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json index 2f1e2018f18..d1aaa472fe9 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json @@ -11889,7 +11889,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 442, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 207, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 257, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 503, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json index a2af41a1a02..0ccb1065979 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json @@ -10913,7 +10913,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 442, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 207, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 257, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 503, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] From a2c5a0222289f298536395fe7678a926c42d779c Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 11 Apr 2024 09:37:33 -0400 Subject: [PATCH 33/49] fix(app): fix rtp slideout issue (#14855) * fix(app): fix rtp slideout issue --- app/src/organisms/ChooseProtocolSlideout/index.tsx | 6 ++---- .../__tests__/ChooseRobotSlideout.test.tsx | 14 +++++++------- app/src/organisms/ChooseRobotSlideout/index.tsx | 2 -- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index fd9085e07cb..d743ef17468 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -222,7 +222,6 @@ export function ChooseProtocolSlideoutComponent( setRunTimeParametersOverrides(clone) }} title={runtimeParam.displayName} - caption={runtimeParam.description} width="100%" dropdownType="neutral" /> @@ -253,7 +252,6 @@ export function ChooseProtocolSlideoutComponent( key={runtimeParam.variableName} type="number" units={runtimeParam.suffix} - placeholder={value.toString()} value={value} title={runtimeParam.displayName} tooltipText={runtimeParam.description} @@ -313,14 +311,14 @@ export function ChooseProtocolSlideoutComponent( }} height="0.813rem" label={ - runtimeParam.value + Boolean(runtimeParam.value) ? t('protocol_details:on') : t('protocol_details:off') } paddingTop={SPACING.spacing2} // manual alignment of SVG with value label /> - {runtimeParam.value + {Boolean(runtimeParam.value) ? t('protocol_details:on') : t('protocol_details:off')} diff --git a/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx b/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx index 18bdf233f75..6c97f4e62c3 100644 --- a/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx +++ b/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx @@ -226,14 +226,14 @@ describe('ChooseRobotSlideout', () => { }) screen.getByText(param.displayName) - if (param.type === 'bool' || 'choices' in param) { + if (param.type === 'bool') { screen.getByText(param.description) - } else { - if (param.type === 'int') { - screen.getByText(`${param.min}-${param.max}`) - } else { - screen.getByText(`${param.min.toFixed(1)}-${param.max.toFixed(1)}`) - } + } + if (param.type === 'int') { + screen.getByText(`${param.min}-${param.max}`) + } + if (param.type === 'float') { + screen.getByText(`${param.min.toFixed(1)}-${param.max.toFixed(1)}`) } }) }) diff --git a/app/src/organisms/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index d19a62a514d..82a7a795363 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -363,7 +363,6 @@ export function ChooseRobotSlideout( } }} title={runtimeParam.displayName} - caption={runtimeParam.description} width="100%" dropdownType="neutral" /> @@ -394,7 +393,6 @@ export function ChooseRobotSlideout( key={runtimeParam.variableName} type="number" units={runtimeParam.suffix} - placeholder={value.toString()} value={value} title={runtimeParam.displayName} tooltipText={runtimeParam.description} From 4c83fc149a0e0510d558002f0999c0cd999b4002 Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 11 Apr 2024 10:08:04 -0400 Subject: [PATCH 34/49] fix(app-shell-odd): fix typo in vite-config (#14864) * fix(app-shell-odd): fix typo in vite-config --- app-shell-odd/vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app-shell-odd/vite.config.ts b/app-shell-odd/vite.config.ts index a3b8351fee6..7848c92bd8d 100644 --- a/app-shell-odd/vite.config.ts +++ b/app-shell-odd/vite.config.ts @@ -8,7 +8,7 @@ import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' -import type {UserConfig} from 'vite' +import type { UserConfig } from 'vite' export default defineConfig( async (): Promise => { @@ -80,7 +80,7 @@ export default defineConfig( '../discovery-client/src/index.ts' ), '@opentrons/usb-bridge/node-client': path.resolve( - '../usb-bridge/node-client/src/inxex.ts' + '../usb-bridge/node-client/src/index.ts' ), }, }, From 8bb14f4edb795e5d126311c4429e3046c74c8f80 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Thu, 11 Apr 2024 10:10:08 -0400 Subject: [PATCH 35/49] feat(app): add input screen for ODD numerical runtime parameters (#14858) closes AUTH-121, AUTH-122, AUTH-224, AUTH-320 --- .../localization/en/protocol_setup.json | 5 +- app/src/atoms/InputField/index.tsx | 61 ++--- .../ChooseProtocolSlideout/index.tsx | 1 + .../organisms/ChooseRobotSlideout/index.tsx | 1 + app/src/organisms/Devices/utils.ts | 14 ++ .../ProtocolSetupParameters/ChooseEnum.tsx | 7 +- .../ProtocolSetupParameters/ChooseNumber.tsx | 164 +++++++++++++ .../ViewOnlyParameters.tsx | 16 +- .../__tests__/ChooseEnum.test.tsx | 4 +- .../__tests__/ViewOnlyParameters.test.tsx | 6 +- .../ProtocolSetupParameters/index.tsx | 221 +++++------------- app/src/organisms/RunTimeControl/hooks.ts | 3 +- app/src/pages/ProtocolDetails/fixtures.ts | 2 +- app/src/pages/ProtocolSetup/index.tsx | 1 + shared-data/js/types.ts | 12 +- 15 files changed, 299 insertions(+), 219 deletions(-) create mode 100644 app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 99b496a3479..fe3f490b1eb 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -227,7 +227,8 @@ "resolve": "Resolve", "restart_setup_and_try": "Restart setup and try using different parameter values.", "restart_setup": "Restart setup", - "restore_default": "Restore default values", + "restore_defaults": "Restore default values", + "restore_default": "Restore default value", "robot_cal_description": "Robot calibration establishes how the robot knows where it is in relation to the deck. Accurate Robot calibration is essential to run protocols successfully. Robot calibration has 3 parts: Deck calibration, Tip Length calibration and Pipette Offset calibration.", "robot_cal_help_title": "How Robot Calibration Works", "robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.", @@ -265,6 +266,8 @@ "usb_port_connected": "USB Port {{port}}", "value": "Value", "values_are_view_only": "Values are view-only", + "value_out_of_range_generic": "Value must be in range", + "value_out_of_range": "Value must be between {{min}}-{{max}}", "view_current_offsets": "View current offsets", "view_moam": "View setup instructions for placing modules of the same type to the robot.", "view_setup_instructions": "View setup instructions", diff --git a/app/src/atoms/InputField/index.tsx b/app/src/atoms/InputField/index.tsx index c1ff5fbeddd..9be59bf1903 100644 --- a/app/src/atoms/InputField/index.tsx +++ b/app/src/atoms/InputField/index.tsx @@ -101,15 +101,16 @@ function Input(props: InputFieldProps): JSX.Element { tooltipText, ...inputProps } = props - const error = props.error != null + const hasError = props.error != null const value = props.isIndeterminate ?? false ? '' : props.value ?? '' const placeHolder = props.isIndeterminate ?? false ? '-' : props.placeholder const [targetProps, tooltipProps] = useHoverTooltip() const OUTER_CSS = css` @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing8}; &:focus-within { - filter: ${error + filter: ${hasError ? 'none' : `drop-shadow(0px 0px 10px ${COLORS.blue50})`}; } @@ -121,7 +122,7 @@ function Input(props: InputFieldProps): JSX.Element { background-color: ${COLORS.white}; border-radius: ${BORDERS.borderRadius4}; padding: ${SPACING.spacing8}; - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50}; + border: 1px ${BORDERS.styleSolid} ${hasError ? COLORS.red50 : COLORS.grey50}; font-size: ${TYPOGRAPHY.fontSizeP}; width: 100%; height: 2rem; @@ -144,17 +145,20 @@ function Input(props: InputFieldProps): JSX.Element { } &:hover { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey60}; } &:focus-visible { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey60}; outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; outline-offset: 3px; } &:focus-within { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.blue50}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.blue50}; } &:disabled { @@ -168,15 +172,16 @@ function Input(props: InputFieldProps): JSX.Element { @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { height: ${size === 'small' ? '4.25rem' : '5rem'}; - box-shadow: ${error ? BORDERS.shadowBig : 'none'}; + box-shadow: ${hasError ? BORDERS.shadowBig : 'none'}; font-size: ${TYPOGRAPHY.fontSize28}; padding: ${SPACING.spacing16} ${SPACING.spacing24}; - border: 2px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50}; + border: 2px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey50}; &:focus-within { box-shadow: none; - border: ${error ? '2px' : '3px'} ${BORDERS.styleSolid} - ${error ? COLORS.red50 : COLORS.blue50}; + border: ${hasError ? '2px' : '3px'} ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.blue50}; } & input { @@ -191,19 +196,17 @@ function Input(props: InputFieldProps): JSX.Element { ` const FORM_BOTTOM_SPACE_STYLE = css` - padding: ${SPACING.spacing4} 0rem; + padding-top: ${SPACING.spacing4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding: ${SPACING.spacing8} 0rem; padding-bottom: 0; } ` const TITLE_STYLE = css` - color: ${error ? COLORS.red50 : COLORS.black90}; + color: ${hasError ? COLORS.red50 : COLORS.black90}; padding-bottom: ${SPACING.spacing8}; - font-size: ${TYPOGRAPHY.fontSizeLabel}; - font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; - line-height: ${TYPOGRAPHY.lineHeight12}; - align-text: ${textAlign}; + text-align: ${textAlign}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { font-size: ${TYPOGRAPHY.fontSize22}; font-weight: ${TYPOGRAPHY.fontWeightRegular}; @@ -214,9 +217,11 @@ function Input(props: InputFieldProps): JSX.Element { const ERROR_TEXT_STYLE = css` color: ${COLORS.red50}; + padding-top: ${SPACING.spacing4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { font-size: ${TYPOGRAPHY.fontSize22}; color: ${COLORS.red50}; + padding-top: ${SPACING.spacing8}; } ` @@ -239,9 +244,14 @@ function Input(props: InputFieldProps): JSX.Element { {title != null ? ( - + {title} - + {tooltipText != null ? ( <> @@ -277,16 +287,6 @@ function Input(props: InputFieldProps): JSX.Element { {props.units} ) : null} - {props.error != null ? ( - - {props.error} - - ) : null} {props.caption != null ? ( ) : null} + {hasError ? ( + + {props.error} + + ) : null} ) } diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index d743ef17468..c1b2c2eea72 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -252,6 +252,7 @@ export function ChooseProtocolSlideoutComponent( key={runtimeParam.variableName} type="number" units={runtimeParam.suffix} + placeholder={runtimeParam.default.toString()} value={value} title={runtimeParam.displayName} tooltipText={runtimeParam.description} diff --git a/app/src/organisms/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index 82a7a795363..c8f5a674257 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -393,6 +393,7 @@ export function ChooseRobotSlideout( key={runtimeParam.variableName} type="number" units={runtimeParam.suffix} + placeholder={runtimeParam.default.toString()} value={value} title={runtimeParam.displayName} tooltipText={runtimeParam.description} diff --git a/app/src/organisms/Devices/utils.ts b/app/src/organisms/Devices/utils.ts index a4d72e0d279..61c133f176b 100644 --- a/app/src/organisms/Devices/utils.ts +++ b/app/src/organisms/Devices/utils.ts @@ -9,7 +9,9 @@ import type { Instruments, PipetteData, PipetteOffsetCalibration, + RunTimeParameterCreateData, } from '@opentrons/api-client' +import type { RunTimeParameter } from '@opentrons/shared-data' /** * formats a string if it is in ISO 8601 date format @@ -89,3 +91,15 @@ export function getShowPipetteCalibrationWarning( }) ?? false ) } + +export function getRunTimeParameterValuesForRun( + runTimeParameters: RunTimeParameter[] +): RunTimeParameterCreateData { + return runTimeParameters.reduce( + (acc, param) => + param.value !== param.default + ? { ...acc, [param.variableName]: param.value } + : acc, + {} + ) +} diff --git a/app/src/organisms/ProtocolSetupParameters/ChooseEnum.tsx b/app/src/organisms/ProtocolSetupParameters/ChooseEnum.tsx index 60e1d7a1b03..1e49e0d8eb0 100644 --- a/app/src/organisms/ProtocolSetupParameters/ChooseEnum.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ChooseEnum.tsx @@ -29,12 +29,7 @@ export function ChooseEnum({ const { makeSnackbar } = useToaster() const { t } = useTranslation(['protocol_setup', 'shared']) - if (parameter.type !== 'str') { - console.error( - `parameter type is expected to be a string for parameter ${parameter.displayName}` - ) - } - const options = parameter.type === 'str' ? parameter.choices : undefined + const options = 'choices' in parameter ? parameter.choices : null const handleOnClick = (newValue: string | number | boolean): void => { setParameter(newValue, parameter.variableName) } diff --git a/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx b/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx new file mode 100644 index 00000000000..da3c34a14c1 --- /dev/null +++ b/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx @@ -0,0 +1,164 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { InputField } from '../../atoms/InputField' +import { useToaster } from '../ToasterOven' +import { ChildNavigation } from '../ChildNavigation' +import { NumericalKeyboard } from '../../atoms/SoftwareKeyboard' +import type { NumberParameter } from '@opentrons/shared-data' + +interface ChooseNumberProps { + handleGoBack: () => void + parameter: NumberParameter + setParameter: (value: number, variableName: string) => void +} + +export function ChooseNumber({ + handleGoBack, + parameter, + setParameter, +}: ChooseNumberProps): JSX.Element | null { + const { makeSnackbar } = useToaster() + + const { i18n, t } = useTranslation(['protocol_setup', 'shared']) + const keyboardRef = React.useRef(null) + const [paramValue, setParamValue] = React.useState( + String(parameter.value) + ) + + // We need to arbitrarily set the value of the keyboard to a string the + // same length as the initial parameter value (as string) when the component mounts + // so that the delete button operates properly on the exisiting input field value. + const [prevKeyboardValue, setPrevKeyboardValue] = React.useState('') + React.useEffect(() => { + const arbitraryInput = new Array(paramValue).join('*') + // @ts-expect-error keyboard should expose for `setInput` method + keyboardRef.current?.setInput(arbitraryInput) + setPrevKeyboardValue(arbitraryInput) + }, []) + + if (parameter.type !== 'int' && parameter.type !== 'float') { + console.log(`Incorrect parameter type: ${parameter.type}`) + return null + } + const handleClickGoBack = (newValue: number): void => { + if (error != null) { + makeSnackbar(t('value_out_of_range_generic')) + } else { + setParameter(newValue, parameter.variableName) + handleGoBack() + } + } + + const handleKeyboardInput = (e: string): void => { + if (prevKeyboardValue.length < e.length) { + const lastDigit = e.slice(-1) + if ( + !'.-'.includes(lastDigit) || + (lastDigit === '.' && !paramValue.includes('.')) || + (lastDigit === '-' && paramValue.length === 0) + ) { + setParamValue(paramValue + lastDigit) + } + } else { + setParamValue(paramValue.slice(0, paramValue.length - 1)) + } + setPrevKeyboardValue(e) + } + + const paramValueAsNumber = Number(paramValue) + const resetValueDisabled = parameter.default === paramValueAsNumber + const { min, max } = parameter + const error = + paramValue === '' || + Number.isNaN(paramValueAsNumber) || + paramValueAsNumber < min || + paramValueAsNumber > max + ? t(`value_out_of_range`, { + min: parameter.type === 'int' ? min : min.toFixed(1), + max: parameter.type === 'int' ? max : max.toFixed(1), + }) + : null + + return ( + <> + { + handleClickGoBack(paramValueAsNumber) + }} + buttonType="tertiaryLowLight" + buttonText={t('restore_default')} + onClickButton={() => + resetValueDisabled + ? makeSnackbar(t('no_custom_values')) + : setParamValue(String(parameter.default)) + } + /> + + + + {parameter.description} + + { + const updatedValue = + parameter.type === 'int' + ? Math.round(e.target.valueAsNumber) + : e.target.valueAsNumber + setParamValue( + Number.isNaN(updatedValue) ? '' : String(updatedValue) + ) + }} + /> + + + { + handleKeyboardInput(e) + }} + /> + + + + ) +} diff --git a/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx b/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx index 09dcaf26c47..3ce9169f77f 100644 --- a/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { formatRunTimeParameterDefaultValue } from '@opentrons/shared-data' +import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ALIGN_CENTER, BORDERS, @@ -16,7 +16,6 @@ import { import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { ChildNavigation } from '../ChildNavigation' import { useToaster } from '../ToasterOven' -import { mockData } from './index' import type { SetupScreens } from '../../pages/ProtocolSetup' @@ -36,8 +35,7 @@ export function ViewOnlyParameters({ makeSnackbar(t('reset_setup')) } - // TODO(jr, 3/18/24): remove mockData - const parameters = mostRecentAnalysis?.runTimeParameters ?? mockData + const parameters = mostRecentAnalysis?.runTimeParameters ?? [] return ( <> @@ -68,9 +66,6 @@ export function ViewOnlyParameters({ {t('value')}
    {parameters.map((parameter, index) => { - // TODO(jr, 3/20/24): plug in the info if the - // parameter changed from the default - const hasCustomValue = true return ( - - {formatRunTimeParameterDefaultValue(parameter, t)} + + {formatRunTimeParameterValue(parameter, t)} - {hasCustomValue ? ( + {parameter.value !== parameter.default ? ( { }) it('calls the prop if reset default is clicked when the default has changed', () => { render(props) - fireEvent.click(screen.getByText('Restore default values')) + fireEvent.click(screen.getByText('Restore default value')) expect(props.setParameter).toHaveBeenCalled() }) it('calls does not call prop if reset default is clicked when the default has not changed', () => { @@ -61,7 +61,7 @@ describe('ChooseEnum', () => { rawValue: 'none', } render(props) - fireEvent.click(screen.getByText('Restore default values')) + fireEvent.click(screen.getByText('Restore default value')) expect(props.setParameter).not.toHaveBeenCalled() }) it('should render the text and buttons for choice param', () => { diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx index 90893117b6f..6e20fe65658 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx @@ -60,6 +60,8 @@ describe('ViewOnlyParameters', () => { fireEvent.click(screen.getAllByRole('button')[0]) expect(props.setSetupScreen).toHaveBeenCalled() }) - // TODO(jr, 3/20/24):test the update chip when - // custom value boolean is wired up + it('renders chip for updated values', () => { + render(props) + screen.getByTestId('Chip_USE_GRIPPER') + }) }) diff --git a/app/src/organisms/ProtocolSetupParameters/index.tsx b/app/src/organisms/ProtocolSetupParameters/index.tsx index 1312844b2ab..ac1f3fd700f 100644 --- a/app/src/organisms/ProtocolSetupParameters/index.tsx +++ b/app/src/organisms/ProtocolSetupParameters/index.tsx @@ -12,159 +12,18 @@ import { import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ProtocolSetupStep } from '../../pages/ProtocolSetup' +import { getRunTimeParameterValuesForRun } from '../Devices/utils' import { ChildNavigation } from '../ChildNavigation' import { ResetValuesModal } from './ResetValuesModal' import { ChooseEnum } from './ChooseEnum' +import { ChooseNumber } from './ChooseNumber' -import type { RunTimeParameter } from '@opentrons/shared-data' +import type { NumberParameter, RunTimeParameter } from '@opentrons/shared-data' import type { LabwareOffsetCreateData } from '@opentrons/api-client' -export const mockData: RunTimeParameter[] = [ - { - value: false, - displayName: 'Dry Run', - variableName: 'DRYRUN', - description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'bool', - default: false, - }, - { - value: true, - displayName: 'Use Gripper', - variableName: 'USE_GRIPPER', - description: 'For using the gripper.', - type: 'bool', - default: true, - }, - { - value: true, - displayName: 'Trash Tips', - variableName: 'TIP_TRASH', - description: - 'to throw tip into the trash or to not throw tip into the trash', - type: 'bool', - default: true, - }, - { - value: true, - displayName: 'Deactivate Temperatures', - variableName: 'DEACTIVATE_TEMP', - description: 'deactivate temperature on the module', - type: 'bool', - default: true, - }, - { - value: 4, - displayName: 'Columns of Samples', - variableName: 'COLUMNS', - description: 'How many columns do you want?', - type: 'int', - min: 1, - max: 14, - default: 4, - }, - { - value: 6, - displayName: 'PCR Cycles', - variableName: 'PCR_CYCLES', - description: 'number of PCR cycles on a thermocycler', - type: 'int', - min: 1, - max: 10, - default: 6, - }, - { - value: 6.5, - displayName: 'EtoH Volume', - variableName: 'ETOH_VOLUME', - description: '70% ethanol volume', - type: 'float', - suffix: 'mL', - min: 1.5, - max: 10.0, - default: 6.5, - }, - { - value: 'none', - displayName: 'Default Module Offsets', - variableName: 'DEFAULT_OFFSETS', - description: 'default module offsets for temp, H-S, and none', - type: 'str', - choices: [ - { - displayName: 'No offsets', - value: 'none', - }, - { - displayName: 'temp offset', - value: '1', - }, - { - displayName: 'heater-shaker offset', - value: '2', - }, - ], - default: 'none', - }, - { - value: 'left', - displayName: 'pipette mount', - variableName: 'mont', - description: 'pipette mount', - type: 'str', - choices: [ - { - displayName: 'Left', - value: 'left', - }, - { - displayName: 'Right', - value: 'right', - }, - ], - default: 'left', - }, - { - value: 'flex', - displayName: 'short test case', - variableName: 'short 2 options', - description: 'this play 2 short options', - type: 'str', - choices: [ - { - displayName: 'OT-2', - value: 'ot2', - }, - { - displayName: 'Flex', - value: 'flex', - }, - ], - default: 'flex', - }, - { - value: 'flex', - displayName: 'long test case', - variableName: 'long 2 options', - description: 'this play 2 long options', - type: 'str', - choices: [ - { - displayName: 'I am kind of long text version', - value: 'ot2', - }, - { - displayName: 'I am kind of long text version. Today is 3/15', - value: 'flex', - }, - ], - default: 'flex', - }, -] - interface ProtocolSetupParametersProps { protocolId: string - runTimeParameters?: RunTimeParameter[] + runTimeParameters: RunTimeParameter[] labwareOffsets?: LabwareOffsetCreateData[] } @@ -181,23 +40,24 @@ export function ProtocolSetupParameters({ chooseValueScreen, setChooseValueScreen, ] = React.useState(null) + const [ + showNumericalInputScreen, + setShowNumericalInputScreen, + ] = React.useState(null) const [resetValuesModal, showResetValuesModal] = React.useState( false ) - - // todo (nd:04/01/2024): remove mock and look at runTimeParameters prop - // const parameters = runTimeParameters ?? [] - const parameters = runTimeParameters ?? mockData + const [startSetup, setStartSetup] = React.useState(false) const [ runTimeParametersOverrides, setRunTimeParametersOverrides, - ] = React.useState(parameters) + ] = React.useState(runTimeParameters) const updateParameters = ( value: boolean | string | number, variableName: string ): void => { - const updatedParameters = parameters.map(parameter => { + const updatedParameters = runTimeParametersOverrides.map(parameter => { if (parameter.variableName === variableName) { return { ...parameter, value } } @@ -212,10 +72,19 @@ export function ProtocolSetupParameters({ setChooseValueScreen(updatedParameter) } } + if ( + showNumericalInputScreen && + showNumericalInputScreen.variableName === variableName + ) { + const updatedParameter = updatedParameters.find( + parameter => parameter.variableName === variableName + ) + if (updatedParameter != null) { + setShowNumericalInputScreen(updatedParameter as NumberParameter) + } + } } - // TODO(jr, 3/20/24): modify useCreateRunMutation to take in optional run time parameters - // newRunTimeParameters will be the param to plug in! const { createRun, isLoading } = useCreateRunMutation({ onSuccess: data => { queryClient @@ -226,8 +95,29 @@ export function ProtocolSetupParameters({ }, }) const handleConfirmValues = (): void => { - createRun({ protocolId, labwareOffsets }) + setStartSetup(true) + createRun({ + protocolId, + labwareOffsets, + runTimeParameterValues: getRunTimeParameterValuesForRun( + runTimeParametersOverrides + ), + }) } + + const handleSetParameter = (parameter: RunTimeParameter): void => { + if ('choices' in parameter) { + setChooseValueScreen(parameter) + } else if (parameter.type === 'bool') { + updateParameters(!parameter.value, parameter.variableName) + } else if (parameter.type === 'int' || parameter.type === 'float') { + setShowNumericalInputScreen(parameter) + } else { + // bad param + console.log('error') + } + } + let children = ( <> history.goBack()} onClickButton={handleConfirmValues} buttonText={t('confirm_values')} - iconName={isLoading ? 'ot-spinner' : undefined} + iconName={isLoading || startSetup ? 'ot-spinner' : undefined} iconPlacement="startIcon" secondaryButtonProps={{ buttonType: 'tertiaryLowLight', - buttonText: t('restore_default'), + buttonText: t('restore_defaults'), onClick: () => showResetValuesModal(true), }} /> @@ -249,6 +139,7 @@ export function ProtocolSetupParameters({ flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} paddingX={SPACING.spacing40} + paddingBottom={SPACING.spacing40} > {runTimeParametersOverrides.map((parameter, index) => { return ( @@ -257,11 +148,7 @@ export function ProtocolSetupParameters({ hasIcon={!(parameter.type === 'bool')} status="general" title={parameter.displayName} - onClickSetupStep={() => - parameter.type === 'bool' - ? updateParameters(!parameter.value, parameter.variableName) - : setChooseValueScreen(parameter) - } + onClickSetupStep={() => handleSetParameter(parameter)} detail={formatRunTimeParameterValue(parameter, t)} description={parameter.description} fontSize="h4" @@ -272,7 +159,7 @@ export function ProtocolSetupParameters({ ) - if (chooseValueScreen != null && chooseValueScreen.type === 'str') { + if (chooseValueScreen != null) { children = ( setChooseValueScreen(null)} @@ -282,7 +169,15 @@ export function ProtocolSetupParameters({ /> ) } - // TODO(jr, 4/1/24): add the int/float component + if (showNumericalInputScreen != null) { + children = ( + setShowNumericalInputScreen(null)} + parameter={showNumericalInputScreen} + setParameter={updateParameters} + /> + ) + } return ( <> diff --git a/app/src/organisms/RunTimeControl/hooks.ts b/app/src/organisms/RunTimeControl/hooks.ts index c56f552b3ae..db042a2ce65 100644 --- a/app/src/organisms/RunTimeControl/hooks.ts +++ b/app/src/organisms/RunTimeControl/hooks.ts @@ -187,5 +187,6 @@ export function useRunErrors(runId: string | null): RunData['errors'] { export function useProtocolHasRunTimeParameters(runId: string | null): boolean { const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - return mostRecentAnalysis?.runTimeParameters != null + const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] + return runTimeParameters.length > 0 } diff --git a/app/src/pages/ProtocolDetails/fixtures.ts b/app/src/pages/ProtocolDetails/fixtures.ts index d1752853bda..dd23bc4623e 100644 --- a/app/src/pages/ProtocolDetails/fixtures.ts +++ b/app/src/pages/ProtocolDetails/fixtures.ts @@ -14,7 +14,7 @@ export const mockRunTimeParameterData: RunTimeParameter[] = [ variableName: 'USE_GRIPPER', description: '', type: 'bool', - default: true, + default: false, value: true, }, { diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index f2fb24feaa5..97499316f27 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -258,6 +258,7 @@ function PrepareToRun({ const history = useHistory() const { makeSnackbar } = useToaster() const hasRunTimeParameters = useProtocolHasRunTimeParameters(runId) + console.log(hasRunTimeParameters) // Watch for scrolling to toggle dropshadow const scrollRef = React.useRef(null) const [isScrolled, setIsScrolled] = React.useState(false) diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 75466e7558e..318db1d04e4 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -590,7 +590,7 @@ export interface AnalysisError { createdAt: string } -export interface NumberParameter { +export interface NumberParameter extends BaseRunTimeParameter { type: NumberParameterType min: number max: number @@ -602,13 +602,13 @@ export interface Choice { value: number | boolean | string } -interface ChoiceParameter { +interface ChoiceParameter extends BaseRunTimeParameter { type: RunTimeParameterType choices: Choice[] default: number | boolean | string } -interface BooleanParameter { +interface BooleanParameter extends BaseRunTimeParameter { type: BooleanParameterType default: boolean } @@ -621,7 +621,6 @@ type RunTimeParameterType = | BooleanParameterType | StringParameterType -type ParameterType = NumberParameter | ChoiceParameter | BooleanParameter interface BaseRunTimeParameter { displayName: string variableName: string @@ -630,7 +629,10 @@ interface BaseRunTimeParameter { suffix?: string } -export type RunTimeParameter = BaseRunTimeParameter & ParameterType +export type RunTimeParameter = + | BooleanParameter + | ChoiceParameter + | NumberParameter // TODO(BC, 10/25/2023): this type (and others in this file) probably belong in api-client, not here export interface CompletedProtocolAnalysis { From a81cc18f3be565baf8fa684cbdbe6bb6fddc5a06 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 11 Apr 2024 11:00:14 -0400 Subject: [PATCH 36/49] fix(shared-data): adapt 96 3.6 to new schema (#14869) The schema changes in edge weren't in release and need to be manually merged. Closes RQA-2558 --- .../general/ninety_six_channel/p1000/3_6.json | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json index a00dce8ef17..c59dfce42ab 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json @@ -7,8 +7,35 @@ "pressFit": { "presses": 1, "increment": 0.0, - "speed": 10.0, - "distance": 13.0, + "speedByTipCount": { + "1": 10.0, + "2": 10.0, + "3": 10.0, + "4": 10.0, + "5": 10.0, + "6": 10.0, + "7": 10.0, + "8": 10.0, + "12": 10.0, + "16": 10.0, + "24": 10.0, + "48": 10.0 + }, + "distanceByTipCount": { + "1": 13.0, + "2": 13.0, + "3": 13.0, + "4": 13.0, + "5": 13.0, + "6": 13.0, + "7": 13.0, + "8": 13.0, + "12": 13.0, + "16": 13.0, + "24": 13.0, + "48": 13.0 + }, + "currentByTipCount": { "1": 0.2, "2": 0.25, From ee6ff25b5358c27de8d2a7f19276fdab38fb68d2 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 11 Apr 2024 11:20:58 -0400 Subject: [PATCH 37/49] chore(api,shared-data): Require Python >=3.10, not >=3.8 (#14867) --- api/setup.py | 4 +--- shared-data/python/setup.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/api/setup.py b/api/setup.py index ae53321ca22..1811b6b4e2d 100755 --- a/api/setup.py +++ b/api/setup.py @@ -46,8 +46,6 @@ def get_version(): "Intended Audience :: Science/Research", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", ] @@ -87,7 +85,7 @@ def read(*parts): if __name__ == "__main__": setup( - python_requires=">=3.8", + python_requires=">=3.10", name=DISTNAME, description=DESCRIPTION, license=LICENSE, diff --git a/shared-data/python/setup.py b/shared-data/python/setup.py index 8aebebcb408..4e1720cb610 100644 --- a/shared-data/python/setup.py +++ b/shared-data/python/setup.py @@ -130,8 +130,6 @@ def get_version(): "Intended Audience :: Science/Research", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", ] @@ -151,7 +149,7 @@ def get_version(): if __name__ == "__main__": setup( - python_requires=">=3.8", + python_requires=">=3.10", name=DISTNAME, description=DESCRIPTION, license=LICENSE, From 332355ecfed9d4439671e6d31ab656a74f6bbd62 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:09:22 -0400 Subject: [PATCH 38/49] fix(protocol-designer): auto-generate trashBin for flex if no pipetting commands exist (#14857) closes AUTH-267 --- .../modals/CreateFileWizard/utils.ts | 15 +- .../src/step-forms/reducers/index.ts | 69 +++++++-- .../src/step-forms/test/utils.test.ts | 127 ++++++++++++++- .../src/step-forms/utils/index.ts | 145 ++++++++++++++++-- 4 files changed, 320 insertions(+), 36 deletions(-) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 2e0e8d54a72..20abcf27cb3 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -66,14 +66,14 @@ export const getCrashableModuleSelected = ( } export const MOVABLE_TRASH_CUTOUTS = [ - { - value: 'cutoutA1', - slot: 'A1', - }, { value: 'cutoutA3', slot: 'A3', }, + { + value: 'cutoutA1', + slot: 'A1', + }, { value: 'cutoutB1', slot: 'B1', @@ -241,13 +241,6 @@ export const getTrashSlot = (values: FormState): string => { ? [WASTE_CHUTE_CUTOUT as string] : [] - if ( - !cutouts.includes(FLEX_TRASH_DEFAULT_SLOT) && - !moduleSlots.includes('A3') - ) { - return FLEX_TRASH_DEFAULT_SLOT - } - const unoccupiedSlot = MOVABLE_TRASH_CUTOUTS.find( cutout => !cutouts.includes(cutout.value) && diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index a19cd27eeac..5d42a31b086 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -67,8 +67,10 @@ import { createPresavedStepForm, getDeckItemIdInSlot, getIdsInRange, + getUnoccupiedSlotForMoveableTrash, } from '../utils' -import { + +import type { CreateModuleAction, CreatePipettesAction, DeleteModuleAction, @@ -1379,6 +1381,12 @@ export const additionalEquipmentInvariantProperties = handleActions { const stagingAreaId = `${uuid()}:stagingArea` const cutoutId = getCutoutIdByAddressableArea( @@ -1531,11 +1539,11 @@ export const additionalEquipmentInvariantProperties = handleActions { it('gets id in array of length 1', () => { expect(getIdsInRange(['X'], 'X', 'X')).toEqual(['X']) @@ -29,3 +31,126 @@ describe('getIdsInRange', () => { expect(getIdsInRange(orderedIds, 'T', 'T')).toEqual(['T']) }) }) +describe('getUnoccupiedSlotForMoveableTrash', () => { + it('returns slot C1 when all other slots are occupied by modules, labware, moveLabware, and staging areas', () => { + const mockPDFile: any = { + commands: [ + { + key: '7353ae60-c85e-45c4-8d69-59ff3a97debd', + commandType: 'loadModule', + params: { + model: 'thermocyclerModuleV2', + location: { slotName: 'B1' }, + moduleId: + '771f390f-01a9-4615-9c4e-4dbfc95844b5:thermocyclerModuleType', + }, + }, + { + key: '82e5d08f-ceae-4eb8-8600-b61a973d47d9', + commandType: 'loadModule', + params: { + model: 'heaterShakerModuleV1', + location: { slotName: 'D1' }, + moduleId: + 'b9df03af-3844-4ae8-a1cf-cae61a6b4992:heaterShakerModuleType', + }, + }, + { + key: '49bc2a29-a7d2-42a6-8610-e07a9ad166df', + commandType: 'loadModule', + params: { + model: 'temperatureModuleV2', + location: { slotName: 'D3' }, + moduleId: + '52bea856-eea6-473c-80df-b316f3559692:temperatureModuleType', + }, + }, + { + key: '864fadd7-f2c1-400a-b2ef-24d0c887a3c8', + commandType: 'loadLabware', + params: { + displayName: 'Opentrons Flex 96 Tip Rack 50 µL', + labwareId: + '88881828-037c-4445-ba57-121164f4a53a:opentrons/opentrons_flex_96_tiprack_50ul/1', + loadName: 'opentrons_flex_96_tiprack_50ul', + namespace: 'opentrons', + version: 1, + location: { slotName: 'C2' }, + }, + }, + { + key: '79994418-d664-4884-9441-4b0fa62bd143', + commandType: 'loadLabware', + params: { + displayName: 'Bio-Rad 96 Well Plate 200 µL PCR', + labwareId: + '733c04a8-ae8c-449f-a1f9-ca3783fdda58:opentrons/biorad_96_wellplate_200ul_pcr/2', + loadName: 'biorad_96_wellplate_200ul_pcr', + namespace: 'opentrons', + version: 2, + location: { addressableAreaName: 'A4' }, + }, + }, + { + key: 'b2170a2c-d202-4129-9cd7-ffa4e35d57bb', + commandType: 'loadLabware', + params: { + displayName: 'Corning 24 Well Plate 3.4 mL Flat', + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + loadName: 'corning_24_wellplate_3.4ml_flat', + namespace: 'opentrons', + version: 2, + location: { slotName: 'B3' }, + }, + }, + { + key: 'fb1807fe-ca16-4f75-b44d-803d704c7d98', + commandType: 'loadLabware', + params: { + displayName: 'Opentrons Flex 96 Tip Rack 50 µL', + labwareId: + '11fdsa8b1-bf4b-4a6c-80cb-b8e5bdfe309b:opentrons/opentrons_flex_96_tiprack_50ul/1', + loadName: 'opentrons_flex_96_tiprack_50ul', + namespace: 'opentrons', + version: 1, + location: { + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + }, + }, + }, + { + commandType: 'moveLabware', + key: '1395243a-958f-4305-9687-52cdaf39f2b6', + params: { + labwareId: + '733c04a8-ae8c-449f-a1f9-ca3783fdda58:opentrons/biorad_96_wellplate_200ul_pcr/2', + strategy: 'usingGripper', + newLocation: { slotName: 'C1' }, + }, + }, + { + commandType: 'moveLabware', + key: '4e39e7ec-4ada-4e3c-8369-1ff7421061a9', + params: { + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + strategy: 'usingGripper', + newLocation: { addressableAreaName: 'A4' }, + }, + }, + ] as CreateCommand[], + } + const mockStagingAreaSlotNames: AddressableAreaName[] = ['A4', 'B4'] + const mockHasWasteChuteCommands = false + + expect( + getUnoccupiedSlotForMoveableTrash( + mockPDFile, + mockHasWasteChuteCommands, + mockStagingAreaSlotNames + ) + ).toStrictEqual('C3') + }) +}) diff --git a/protocol-designer/src/step-forms/utils/index.ts b/protocol-designer/src/step-forms/utils/index.ts index 73596b481c6..d9b2d108132 100644 --- a/protocol-designer/src/step-forms/utils/index.ts +++ b/protocol-designer/src/step-forms/utils/index.ts @@ -6,20 +6,23 @@ import { getPipetteSpecsV2, GEN_ONE_MULTI_PIPETTES, THERMOCYCLER_MODULE_TYPE, + THERMOCYCLER_MODULE_V2, + WASTE_CHUTE_CUTOUT, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { SPAN7_8_10_11_SLOT, TC_SPAN_SLOTS } from '../../constants' import { hydrateField } from '../../steplist/fieldLevel' import { LabwareDefByDefURI } from '../../labware-defs' -import type { DeckSlotId, ModuleType } from '@opentrons/shared-data' +import { getCutoutIdByAddressableArea } from '../../utils' import type { - AdditionalEquipmentOnDeck, - InitialDeckSetup, - ModuleOnDeck, - FormPipettesByMount, - FormPipette, - LabwareOnDeck as LabwareOnDeckType, -} from '../types' -import type { DeckSlot } from '../../types' + AddressableAreaName, + CutoutId, + DeckSlotId, + LoadLabwareCreateCommand, + LoadModuleCreateCommand, + ModuleType, + MoveLabwareCreateCommand, +} from '@opentrons/shared-data' import type { NormalizedPipette, NormalizedPipetteById, @@ -28,9 +31,54 @@ import type { InvariantContext, ModuleEntity, } from '@opentrons/step-generation' +import type { DeckSlot } from '../../types' import type { FormData } from '../../form-types' +import type { PDProtocolFile } from '../../file-types' +import type { + AdditionalEquipmentOnDeck, + InitialDeckSetup, + ModuleOnDeck, + FormPipettesByMount, + FormPipette, + LabwareOnDeck as LabwareOnDeckType, +} from '../types' export { createPresavedStepForm } from './createPresavedStepForm' +const MOVABLE_TRASH_CUTOUTS = [ + { + value: 'cutoutA3', + slot: 'A3', + }, + { + value: 'cutoutA1', + slot: 'A1', + }, + { + value: 'cutoutB1', + slot: 'B1', + }, + { + value: 'cutoutB3', + slot: 'B3', + }, + { + value: 'cutoutC1', + slot: 'C1', + }, + { + value: 'cutoutC3', + slot: 'C3', + }, + { + value: 'cutoutD1', + slot: 'D1', + }, + { + value: 'cutoutD3', + slot: 'D3', + }, +] + const slotToCutoutOt2Map: { [key: string]: string } = { '1': 'cutout1', '2': 'cutout2', @@ -248,3 +296,82 @@ export function getHydratedForm( // @ts-expect-error(sa, 2021-6-14):type this properly in #3161 return hydratedForm } + +export const getUnoccupiedSlotForMoveableTrash = ( + file: PDProtocolFile, + hasWasteChuteCommands: boolean, + stagingAreaSlotNames: AddressableAreaName[] +): string => { + const wasteChuteSlot = hasWasteChuteCommands ? [WASTE_CHUTE_CUTOUT] : [] + const stagingAreaCutoutIds = stagingAreaSlotNames.map(slotName => + getCutoutIdByAddressableArea( + slotName, + 'stagingAreaRightSlot', + FLEX_ROBOT_TYPE + ) + ) + const allLoadLabwareSlotNames = Object.values(file.commands) + .filter( + (command): command is LoadLabwareCreateCommand => + command.commandType === 'loadLabware' + ) + .reduce((acc: string[], command) => { + const location = command.params.location + if ( + location !== 'offDeck' && + location !== null && + 'slotName' in location + ) { + return [...acc, location.slotName] + } + return acc + }, []) + + const allLoadModuleSlotNames = Object.values(file.commands) + .filter( + (command): command is LoadModuleCreateCommand => + command.commandType === 'loadModule' + ) + .flatMap(command => { + // special-casing Thermocycler + if (command.params.model === THERMOCYCLER_MODULE_V2) { + return ['A1', command.params.location.slotName] + } else { + return command.params.location.slotName + } + }) + + const allMoveLabwareLocations = Object.values(file.commands) + .filter( + (command): command is MoveLabwareCreateCommand => + command.commandType === 'moveLabware' + ) + .reduce((acc: string[], command) => { + const newLocation = command.params.newLocation + if ( + newLocation !== 'offDeck' && + newLocation !== null && + 'slotName' in newLocation + ) { + return [...acc, newLocation.slotName] + } + return acc + }, []) + + const unoccupiedSlot = MOVABLE_TRASH_CUTOUTS.find( + cutout => + !allLoadLabwareSlotNames.includes(cutout.slot) && + !allLoadModuleSlotNames.includes(cutout.slot) && + !allMoveLabwareLocations.includes(cutout.slot) && + !wasteChuteSlot.includes(cutout.value as typeof WASTE_CHUTE_CUTOUT) && + !stagingAreaCutoutIds.includes(cutout.value as CutoutId) + ) + if (unoccupiedSlot == null) { + console.error( + 'Expected to find an unoccupied slot for auto-generating a trash bin but could not' + ) + return '' + } + + return unoccupiedSlot.slot +} From 9b45ea17e779bba60d16691d135c755ed8bb83d9 Mon Sep 17 00:00:00 2001 From: Rhyann Clarke <146747548+rclarke0@users.noreply.github.com> Date: Thu, 11 Apr 2024 14:14:53 -0400 Subject: [PATCH 39/49] Module ramp rate to google sheet (#14868) # Overview Calculates module ramp rates based on run log and uploads to google sheet. # Test Plan Ramp rate script tested on all three modules with different robots. # Changelog Created module ramp rate script to find ramp rate runs in run log folder and upload ramp rates to abr-run-data sheet Also changed IP address in error recording to a user input rather than an input in order to allow the command to be created into a desktop shortcut. # Review requests # Risk assessment --- .../data_collection/abr_google_drive.py | 11 +- .../data_collection/abr_robot_error.py | 20 +-- .../data_collection/module_ramp_rates.py | 154 ++++++++++++++++++ 3 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 abr-testing/abr_testing/data_collection/module_ramp_rates.py diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index 6470f1e0410..a186019b35b 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -43,6 +43,8 @@ def create_data_dictionary( file_results = json.load(file) else: continue + if not isinstance(file_results, dict): + continue run_id = file_results.get("run_id", "NaN") if run_id in runs_to_save: robot = file_results.get("robot_name") @@ -107,7 +109,14 @@ def create_data_dictionary( hs_dict = read_robot_logs.hs_commands(file_results) tm_dict = read_robot_logs.temperature_module_commands(file_results) notes = {"Note1": "", "Jira Link": issue_url} - row_2 = {**row, **all_modules, **notes, **hs_dict, **tm_dict, **tc_dict} + row_2 = { + **row, + **all_modules, + **notes, + **hs_dict, + **tm_dict, + **tc_dict, + } headers = list(row_2.keys()) runs_and_robots[run_id] = row_2 else: diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index b139b5a3ade..231b8077eed 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -91,13 +91,6 @@ def get_error_info_from_robot( nargs=1, help="Path to long term storage directory for run logs.", ) - parser.add_argument( - "robot_ip", - metavar="ROBOT_IP", - type=str, - nargs=1, - help="IP address of robot as string.", - ) parser.add_argument( "jira_api_token", metavar="JIRA_API_TOKEN", @@ -130,14 +123,18 @@ def get_error_info_from_robot( ) args = parser.parse_args() storage_directory = args.storage_directory[0] - ip = args.robot_ip[0] + ip = str(input("Enter Robot IP: ")) url = "https://opentrons.atlassian.net" api_token = args.jira_api_token[0] email = args.email[0] board_id = args.board_id[0] reporter_id = args.reporter_id[0] ticket = jira_tool.JiraTicket(url, api_token, email) - error_runs = get_error_runs_from_robot(ip) + try: + error_runs = get_error_runs_from_robot(ip) + except requests.exceptions.InvalidURL: + print("Invalid IP address.") + sys.exit() one_run = error_runs[-1] # Most recent run with error. ( summary, @@ -147,7 +144,7 @@ def get_error_info_from_robot( whole_description_str, run_log_file_path, ) = get_error_info_from_robot(ip, one_run, storage_directory) - # get calibration data + # Get Calibration Data saved_file_path_calibration, calibration = read_robot_logs.get_calibration_offsets( ip, storage_directory ) @@ -156,6 +153,7 @@ def get_error_info_from_robot( # TODO: make argument or see if I can get rid of with using board_id. project_key = "RABR" parent_key = project_key + "-" + robot[-1] + # TODO: read board to see if ticket for run id already exists. # CREATE TICKET issue_key = ticket.create_ticket( summary, @@ -172,7 +170,7 @@ def get_error_info_from_robot( issue_url = ticket.open_issue(issue_key) # MOVE FILES TO ERROR FOLDER. error_files = [saved_file_path_calibration, run_log_file_path] + file_paths - error_folder_path = os.path.join(storage_directory, str("RABR-238")) + error_folder_path = os.path.join(storage_directory, issue_key) os.makedirs(error_folder_path, exist_ok=True) for source_file in error_files: destination_file = os.path.join( diff --git a/abr-testing/abr_testing/data_collection/module_ramp_rates.py b/abr-testing/abr_testing/data_collection/module_ramp_rates.py new file mode 100644 index 00000000000..dc402071bb7 --- /dev/null +++ b/abr-testing/abr_testing/data_collection/module_ramp_rates.py @@ -0,0 +1,154 @@ +"""Get ramp rates of modules.""" +from abr_testing.automation import google_sheets_tool +from abr_testing.data_collection import read_robot_logs +import gspread # type: ignore[import] +import argparse +import os +import sys +import json +from datetime import datetime +from typing import Dict, Any +import requests + + +def ramp_rate(file_results: Dict[str, Any]) -> Dict[int, float]: + """Get ramp rates.""" + i = 0 + commands = file_results["commands"] + for command in commands: + commandType = command["commandType"] + if ( + commandType == "thermocycler/setTargetBlockTemperature" + or commandType == "temperatureModule/setTargetTemperature" + or commandType == "heaterShaker/setTargetTemperature" + ): + temp = command["params"].get("celsius", 0.0) + if ( + commandType == "thermocycler/waitForBlockTemperature" + or commandType == "temperatureModule/waitForTemperature" + or commandType == "heaterShaker/waitForTemperature" + ): + start_time = datetime.strptime( + command.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + end_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + duration = (end_time - start_time).total_seconds() + i += 1 + temps_and_durations[duration] = temp + ramp_rates = {} + times = list(temps_and_durations.keys()) + for i in range(len(times) - 1): + time1 = times[i] + time2 = times[i + 1] + temp1 = temps_and_durations[time1] + temp2 = temps_and_durations[time2] + ramp_rate = (temp2 - temp1) / (time2) + ramp_rates[i] = ramp_rate + return ramp_rates + + +if __name__ == "__main__": + # SCRIPT ARGUMENTS + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "google_sheet_name", + metavar="GOOGLE_SHEET_NAME", + type=str, + nargs=1, + help="Google sheet name.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + google_sheet_name = args.google_sheet_name[0] + # FIND CREDENTIALS FILE + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + # CONNECT TO GOOGLE SHEET + try: + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 1 + ) + print(f"Connected to google sheet: {google_sheet_name}") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() + run_ids_on_sheet = google_sheet.get_column(2) + runs_and_robots = {} + for filename in os.listdir(storage_directory): + file_path = os.path.join(storage_directory, filename) + if file_path.endswith(".json"): + with open(file_path) as file: + file_results = json.load(file) + else: + continue + # CHECK if file is ramp rate run + run_id = file_results.get("run_id", None) + temps_and_durations: Dict[float, float] = dict() + if run_id is not None and run_id not in run_ids_on_sheet: + + ramp_rates = ramp_rate(file_results) + protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") + if "Ramp Rate" in protocol_name: + ip = filename.split("_")[0] + if len(ramp_rates) > 1: + cooling_ramp_rate = abs(min(ramp_rates.values())) + heating_ramp_rate = abs(max(ramp_rates.values())) + start_time = datetime.strptime( + file_results.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + start_date = str(start_time.date()) + module_serial_number = file_results["modules"][0].get( + "serialNumber", "NaN" + ) + try: + response = requests.get( + f"http://{ip}:31950/modules", + headers={"opentrons-version": "3"}, + ) + modules = response.json() + for module in modules["data"]: + if module["serialNumber"] == module_serial_number: + firmwareVersion = module["firmwareVersion"] + else: + firmwareVersion = "NaN" + except requests.exceptions.ConnectionError: + firmwareVersion = "NaN" + row = { + "Robot": file_results.get("robot_name", ""), + "Run_ID": run_id, + "Protocol_Name": file_results["protocol"]["metadata"].get( + "protocolName", "" + ), + "Software Version": file_results.get("API_Version", ""), + "Firmware Version": firmwareVersion, + "Date": start_date, + "Serial Number": module_serial_number, + "Approx. Average Heating Ramp Rate (C/s)": heating_ramp_rate, + "Approx. Average Cooling Ramp Rate (C/s)": cooling_ramp_rate, + } + headers = list(row.keys()) + runs_and_robots[run_id] = row + read_robot_logs.write_to_local_and_google_sheet( + runs_and_robots, + storage_directory, + google_sheet_name, + google_sheet, + headers, + ) + else: + continue From fa6066f91ff1a6fc88060c2e14b4ae12d3f3c625 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Thu, 11 Apr 2024 14:34:49 -0400 Subject: [PATCH 40/49] feat(protocol-designer): export and announcement modal for PD 8.1 (#14870) closes AUTH-8 AUTH-9 --- .../cypress/integration/migrations.spec.js | 2 +- .../components/FileSidebar/FileSidebar.tsx | 8 ++--- .../__tests__/FileSidebar.test.tsx | 7 ++++ .../AnnouncementModal/announcements.tsx | 34 +++++++++++++++++++ .../src/localization/en/alert.json | 4 +-- .../src/localization/en/modal.json | 8 +++++ protocol-designer/src/tutorial/index.ts | 3 +- 7 files changed, 58 insertions(+), 8 deletions(-) diff --git a/protocol-designer/cypress/integration/migrations.spec.js b/protocol-designer/cypress/integration/migrations.spec.js index 6c1d01a0ee7..303c7b91701 100644 --- a/protocol-designer/cypress/integration/migrations.spec.js +++ b/protocol-designer/cypress/integration/migrations.spec.js @@ -127,7 +127,7 @@ describe('Protocol fixtures migrate and match snapshots', () => { cy.get('div') .contains( - 'This protocol can only run on app and robot server version 7.1 or higher' + 'This protocol can only run on app and robot server version 7.2.0 or higher' ) .should('exist') cy.get('button').contains('continue', { matchCase: false }).click() diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index e05a80e3163..31bdfa60723 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -237,9 +237,9 @@ export function v8WarningContent(t: any): JSX.Element { return (

    - {t(`hint.export_v8_protocol_7_1.body1`)}{' '} - {t(`hint.export_v8_protocol_7_1.body2`)} - {t(`hint.export_v8_protocol_7_1.body3`)} + {t(`hint.export_v8_1_protocol_7_2.body1`)}{' '} + {t(`hint.export_v8_1_protocol_7_2.body2`)} + {t(`hint.export_v8_1_protocol_7_2.body3`)}

    ) @@ -350,7 +350,7 @@ export function FileSidebar(): JSX.Element { content: React.ReactNode } => { return { - hintKey: 'export_v8_protocol_7_1', + hintKey: 'export_v8_1_protocol_7_2', content: v8WarningContent(t), } } diff --git a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx index a9d2978b981..827af5a2aa8 100644 --- a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx +++ b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx @@ -74,6 +74,13 @@ describe('FileSidebar', () => { vi.resetAllMocks() cleanup() }) + it('renders the file sidebar and exports with blocking hint for exporting', () => { + vi.mocked(useBlockingHint).mockReturnValue(
    mock blocking hint
    ) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + expect(vi.mocked(useBlockingHint)).toHaveBeenCalled() + screen.getByText('mock blocking hint') + }) it('renders the file sidebar and buttons work as expected with no warning upon export', () => { render() screen.getByText('Protocol File') diff --git a/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx b/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx index aab430bf549..b10c6d75407 100644 --- a/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx +++ b/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx @@ -265,5 +265,39 @@ export const useAnnouncements = (): Announcement[] => { ), }, + { + announcementKey: 'customParamsAndMultiTipAndModule8.1', + image: , + heading: t('announcements.header', { pd: PD }), + message: ( + <> +

    + {t('announcements.customParamsAndMultiTipAndModule.body1', { + pd: PD, + })} +

    +
      +
    • {t('announcements.customParamsAndMultiTipAndModule.body2')}
    • +
    • + }} + /> +
    • +
    • {t('announcements.customParamsAndMultiTipAndModule.body4')}
    • +
    • {t('announcements.customParamsAndMultiTipAndModule.body5')}
    • +
    +

    + }} + values={{ app: APP }} + /> +

    + + ), + }, ] } diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index 4548d19e57c..999c43500b0 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -53,10 +53,10 @@ "title": "Missing labware", "body": "One or more module has no labware on it. We recommend you add labware before proceeding" }, - "export_v8_protocol_7_1": { + "export_v8_1_protocol_7_2": { "title": "Robot and app update may be required", "body1": "This protocol can only run on app and robot server version", - "body2": "7.1 or higher", + "body2": "7.2.0 or higher", "body3": ". Please ensure your robot is updated to the correct version." }, "change_magnet_module_model": { diff --git a/protocol-designer/src/localization/en/modal.json b/protocol-designer/src/localization/en/modal.json index a07cb3b1310..fd85b5a8001 100644 --- a/protocol-designer/src/localization/en/modal.json +++ b/protocol-designer/src/localization/en/modal.json @@ -42,6 +42,14 @@ "deckConfigAnd96Channel": { "body1": "Introducing the {{pd}} 8.0 with deck configuration and 96-channel pipette support!", "body2": "All protocols now require {{app}} version 7.1+ to run." + }, + "customParamsAndMultiTipAndModule": { + "body1": "Introducing {{pd}} 8.1. Starting today, you will be able to:", + "body2": "Customize blowout speed and height.", + "body3": "Adjust horizontal position within a well when aspirating, dispensing, or mixing.", + "body4": "Assign up to three types of tip racks to a single pipette.", + "body5": "Add multiple Temperature Modules to the deck (Flex only).", + "body6": "All protocols require {{app}} version 7.2.0 or later to run." } }, "labware_selection": { diff --git a/protocol-designer/src/tutorial/index.ts b/protocol-designer/src/tutorial/index.ts index 58a0f522c60..ecc17f49bb4 100644 --- a/protocol-designer/src/tutorial/index.ts +++ b/protocol-designer/src/tutorial/index.ts @@ -11,7 +11,7 @@ type HintKey = // normal hints | 'waste_chute_warning' // blocking hints | 'custom_labware_with_modules' - | 'export_v8_protocol_7_1' + | 'export_v8_1_protocol_7_2' | 'change_magnet_module_model' // DEPRECATED HINTS (keep a record to avoid name collisions with old persisted dismissal states) // 'export_v4_protocol' @@ -20,5 +20,6 @@ type HintKey = // normal hints // | 'export_v6_protocol_6_10' // | 'export_v6_protocol_6_20' // | 'export_v7_protocol_7_0' +// | 'export_v8_protocol_7_1' export { actions, rootReducer, selectors } export type { RootState, HintKey } From 9be2f8fbe9d2afc6f2ee41307e202e721611db02 Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 11 Apr 2024 15:27:45 -0400 Subject: [PATCH 41/49] fix(app): remove unnecessary console.log (#14880) * fix(app): remove unnecessary console.log --- app/src/organisms/ChooseProtocolSlideout/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index c1b2c2eea72..6a1c1a0aa8c 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -105,7 +105,6 @@ export function ChooseProtocolSlideoutComponent( const runTimeParametersFromAnalysis = selectedProtocol?.mostRecentAnalysis?.runTimeParameters ?? [] - console.log('runTimeParametersFromAnalysis', runTimeParametersFromAnalysis) const hasRunTimeParameters = runTimeParametersFromAnalysis.length > 0 From 044b37fc01d83bcd9f9cfb2400ea3854de966469 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:46:48 -0400 Subject: [PATCH 42/49] fix(protocol-designer, components): discarding vs delete step form button text (#14872) closes AUTH-319 --- components/src/lists/TitledList.tsx | 23 ++++++++++++------- .../src/localization/en/modal.json | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/components/src/lists/TitledList.tsx b/components/src/lists/TitledList.tsx index 58a12d19b6e..4fbe4ab58ee 100644 --- a/components/src/lists/TitledList.tsx +++ b/components/src/lists/TitledList.tsx @@ -2,10 +2,13 @@ import * as React from 'react' import cx from 'classnames' -import styles from './lists.module.css' import { Icon } from '../icons' +import { StyledText } from '../atoms' +import { COLORS } from '../helix-design-system' import type { IconName, IconProps } from '../icons' +import styles from './lists.module.css' + // TODO(bc, 2021-03-31): reconsider whether this belongs in components library // it is bloated with application specific functionality @@ -98,6 +101,15 @@ export function TitledList(props: TitledListProps): JSX.Element { iconProps && iconProps.className ) + let textColor = '' + if (disabled) { + // the below hex code is for our legacy --c-font-disabled to match other text colors + textColor = '#9c9c9c' + } else if (props.selected && !disabled) { + // the below hex code is for our legacy --c-highlight to match other text colors + textColor = '#5fd8ee' + } + return (
    )} -

    + {props.title} -

    + {collapsible && (
    Date: Fri, 12 Apr 2024 09:54:16 -0400 Subject: [PATCH 43/49] feat(robot-server): add runtime parameter definitions to run summary (#14866) Adds the runtime parameter definitions to the run summary for both current and non current runs, accessible via the GET /runs and /runs/{run_id} endpoints. --- .../persistence/_migrations/v3_to_v4.py | 6 + .../robot_server/persistence/pydantic.py | 19 +- .../persistence/tables/schema_4.py | 7 + .../robot_server/runs/run_controller.py | 1 + .../robot_server/runs/run_data_manager.py | 20 +- robot-server/robot_server/runs/run_models.py | 20 +- robot-server/robot_server/runs/run_store.py | 41 +++- .../test_json_v6_protocol_run.tavern.yaml | 1 + .../test_json_v7_protocol_run.tavern.yaml | 1 + .../runs/test_protocol_run.tavern.yaml | 2 + ...t_run_queued_protocol_commands.tavern.yaml | 1 + ...t_run_with_run_time_parameters.tavern.yaml | 203 ++++++++++++++++++ robot-server/tests/persistence/test_tables.py | 1 + .../tests/runs/test_run_controller.py | 18 +- .../tests/runs/test_run_data_manager.py | 73 ++++++- robot-server/tests/runs/test_run_store.py | 109 +++++++++- 16 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml diff --git a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py index 8b4445aaec3..b67d11d34ec 100644 --- a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py +++ b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py @@ -3,6 +3,7 @@ Summary of changes from schema 3: - Adds a new "run_time_parameter_values_and_defaults" column to analysis table +- Adds a new "run_time_parameters" column to run table """ from pathlib import Path @@ -50,3 +51,8 @@ def add_column( schema_4.analysis_table.name, schema_4.analysis_table.c.run_time_parameter_values_and_defaults, ) + add_column( + dest_engine, + schema_4.run_table.name, + schema_4.run_table.c.run_time_parameters, + ) diff --git a/robot-server/robot_server/persistence/pydantic.py b/robot-server/robot_server/persistence/pydantic.py index c3486394ad4..c56312ec166 100644 --- a/robot-server/robot_server/persistence/pydantic.py +++ b/robot-server/robot_server/persistence/pydantic.py @@ -1,7 +1,8 @@ """Store Pydantic objects in the SQL database.""" -from typing import Type, TypeVar -from pydantic import BaseModel, parse_raw_as +import json +from typing import Type, TypeVar, List, Sequence +from pydantic import BaseModel, parse_raw_as, parse_obj_as _BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) @@ -17,6 +18,16 @@ def pydantic_to_json(obj: BaseModel) -> str: ) -def json_to_pydantic(model: Type[_BaseModelT], json: str) -> _BaseModelT: +def pydantic_list_to_json(obj_list: Sequence[BaseModel]) -> str: + """Serialize a list of Pydantic objects for storing in the SQL database.""" + return json.dumps([obj.dict(by_alias=True, exclude_none=True) for obj in obj_list]) + + +def json_to_pydantic(model: Type[_BaseModelT], json_str: str) -> _BaseModelT: """Parse a Pydantic object stored in the SQL database.""" - return parse_raw_as(model, json) + return parse_raw_as(model, json_str) + + +def json_to_pydantic_list(model: Type[_BaseModelT], json_str: str) -> List[_BaseModelT]: + """Parse a list of Pydantic objects stored in the SQL database.""" + return [parse_obj_as(model, obj_dict) for obj_dict in json.loads(json_str)] diff --git a/robot-server/robot_server/persistence/tables/schema_4.py b/robot-server/robot_server/persistence/tables/schema_4.py index 47d29d3d8f3..d1662bf7adc 100644 --- a/robot-server/robot_server/persistence/tables/schema_4.py +++ b/robot-server/robot_server/persistence/tables/schema_4.py @@ -85,6 +85,13 @@ sqlalchemy.Column("engine_status", sqlalchemy.String, nullable=True), # column added in schema v1 sqlalchemy.Column("_updated_at", UTCDateTime, nullable=True), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameters", + # Stores a JSON string. See RunStore. + sqlalchemy.String, + nullable=True, + ), ) action_table = sqlalchemy.Table( diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 782754c1da6..923c9cfa64e 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -106,4 +106,5 @@ async def _run_protocol_and_insert_result( run_id=self._run_id, summary=result.state_summary, commands=result.commands, + run_time_parameters=result.parameters, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 570537a135c..154a1584823 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -22,13 +22,14 @@ from .run_store import RunResource, RunStore, BadRunResource, BadStateSummary from .run_models import Run, BadRun, RunDataError -from opentrons.protocol_engine.types import DeckConfigurationType +from opentrons.protocol_engine.types import DeckConfigurationType, RunTimeParameter def _build_run( run_resource: Union[RunResource, BadRunResource], state_summary: Union[StateSummary, BadStateSummary], current: bool, + run_time_parameters: List[RunTimeParameter], ) -> Union[Run, BadRun]: # TODO(mc, 2022-05-16): improve persistence strategy # such that this default summary object is not needed @@ -49,6 +50,7 @@ def _build_run( completedAt=state_summary.completedAt, startedAt=state_summary.startedAt, liquids=state_summary.liquids, + runTimeParameters=run_time_parameters, ) errors: List[EnumeratedError] = [] @@ -102,6 +104,7 @@ def _build_run( completedAt=state.completedAt, startedAt=state.startedAt, liquids=state.liquids, + runTimeParameters=run_time_parameters, ) @@ -172,6 +175,7 @@ async def create( run_id=prev_run_id, summary=prev_run_result.state_summary, commands=prev_run_result.commands, + run_time_parameters=prev_run_result.parameters, ) state_summary = await self._engine_store.create( run_id=run_id, @@ -196,6 +200,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, + run_time_parameters=[], ) def get(self, run_id: str) -> Union[Run, BadRun]: @@ -215,9 +220,10 @@ def get(self, run_id: str) -> Union[Run, BadRun]: """ run_resource = self._run_store.get(run_id=run_id) state_summary = self._get_state_summary(run_id=run_id) + parameters = self._get_run_time_parameters(run_id=run_id) current = run_id == self._engine_store.current_run_id - return _build_run(run_resource, state_summary, current) + return _build_run(run_resource, state_summary, current, parameters) def get_run_loaded_labware_definitions( self, run_id: str @@ -260,6 +266,7 @@ def get_all(self, length: Optional[int]) -> List[Union[Run, BadRun]]: run_resource=run_resource, state_summary=self._get_state_summary(run_resource.run_id), current=run_resource.run_id == self._engine_store.current_run_id, + run_time_parameters=self._get_run_time_parameters(run_resource.run_id), ) for run_resource in self._run_store.get_all(length) ] @@ -310,15 +317,18 @@ async def update(self, run_id: str, current: Optional[bool]) -> Union[Run, BadRu run_id=run_id, summary=state_summary, commands=commands, + run_time_parameters=parameters, ) else: state_summary = self._engine_store.engine.state_view.get_summary() + parameters = self._engine_store.runner.run_time_parameters run_resource = self._run_store.get(run_id=run_id) return _build_run( run_resource=run_resource, state_summary=state_summary, current=next_current, + run_time_parameters=parameters, ) def get_commands_slice( @@ -385,3 +395,9 @@ def _get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary def _get_good_state_summary(self, run_id: str) -> Optional[StateSummary]: summary = self._get_state_summary(run_id) return summary if isinstance(summary, StateSummary) else None + + def _get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + if run_id == self._engine_store.current_run_id: + return self._engine_store.runner.run_time_parameters + else: + return self._run_store.get_run_time_parameters(run_id=run_id) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 7da6e0b0a5d..c93049bfef4 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -18,7 +18,7 @@ Liquid, CommandNote, ) -from opentrons.protocol_engine.types import RunTimeParamValuesType +from opentrons.protocol_engine.types import RunTimeParameter, RunTimeParamValuesType from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel from robot_server.errors.error_responses import ErrorDetails @@ -121,6 +121,15 @@ class Run(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( @@ -185,6 +194,15 @@ class BadRun(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 5aa6dbae96b..b86ec8e19ea 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -12,6 +12,7 @@ from opentrons.util.helpers import utc_now from opentrons.protocol_engine import StateSummary, CommandSlice from opentrons.protocol_engine.commands import Command +from opentrons.protocol_engine.types import RunTimeParameter from opentrons_shared_data.errors.exceptions import ( EnumeratedError, @@ -25,7 +26,12 @@ run_command_table, action_table, ) -from robot_server.persistence.pydantic import json_to_pydantic, pydantic_to_json +from robot_server.persistence.pydantic import ( + json_to_pydantic, + pydantic_to_json, + json_to_pydantic_list, + pydantic_list_to_json, +) from robot_server.protocols.protocol_store import ProtocolNotFoundError from .action_models import RunAction, RunActionType @@ -102,6 +108,7 @@ def update_run_state( run_id: str, summary: StateSummary, commands: List[Command], + run_time_parameters: List[RunTimeParameter], ) -> RunResource: """Update the run's state summary and commands list. @@ -109,6 +116,7 @@ def update_run_state( run_id: The run to update summary: The run's equipment and status summary. commands: The run's commands. + run_time_parameters: The run's run time parameters, if any. Returns: The run resource. @@ -124,6 +132,7 @@ def update_run_state( run_id=run_id, state_summary=summary, engine_status=summary.status, + run_time_parameters=run_time_parameters, ) ) ) @@ -346,6 +355,33 @@ def get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary] ) ) + @lru_cache(maxsize=_CACHE_ENTRIES) + def get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + """Get the archived run time parameters. + + This is a list of the run's parameter definitions (if any), + including the values used in the run itself, along with the default value, + constraints and associated names and descriptions. + """ + select_run_data = sqlalchemy.select(run_table.c.run_time_parameters).where( + run_table.c.id == run_id + ) + + with self._sql_engine.begin() as transaction: + row = transaction.execute(select_run_data).one() + + try: + return ( + json_to_pydantic_list(RunTimeParameter, row.run_time_parameters) # type: ignore[arg-type] + if row.run_time_parameters is not None + else [] + ) + except ValidationError: + log.warning( + f"Error retrieving run time parameters for {run_id}", exc_info=True + ) + return [] + def get_commands_slice( self, run_id: str, @@ -476,6 +512,7 @@ def _clear_caches(self) -> None: self.get_all.cache_clear() self.get_state_summary.cache_clear() self.get_command.cache_clear() + self.get_run_time_parameters.cache_clear() # The columns that must be present in a row passed to _convert_row_to_run(). @@ -552,9 +589,11 @@ def _convert_state_to_sql_values( run_id: str, state_summary: StateSummary, engine_status: str, + run_time_parameters: List[RunTimeParameter], ) -> Dict[str, object]: return { "state_summary": pydantic_to_json(state_summary), "engine_status": engine_status, "_updated_at": utc_now(), + "run_time_parameters": pydantic_list_to_json(run_time_parameters), } diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index 4ff631bf277..e7ac3483dd7 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -50,6 +50,7 @@ stages: displayName: Water description: Liquid H2O displayColor: '#7332a8' + runTimeParameters: [] protocolId: '{protocol_id}' - name: Execute a setup command diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 317d339fbbf..bdc4ad4a66d 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -45,6 +45,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] liquids: - id: waterId displayName: Water diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 48dc570d6c9..67d1511a666 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -42,6 +42,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] protocolId: '{protocol_id}' liquids: [] save: @@ -237,6 +238,7 @@ stages: createdAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" startedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" liquids: [] + runTimeParameters: [] completedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" errors: [] pipettes: [] diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index cc8cea69356..0d4a0010281 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -94,6 +94,7 @@ stages: labware: [] labwareOffsets: [] liquids: [] + runTimeParameters: [] modules: [] pipettes: [] status: 'idle' 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 new file mode 100644 index 00000000000..d7f075b18cb --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -0,0 +1,203 @@ +test_name: Test the run endpoints with run time parameters + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + - name: Upload a protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + status_code: 201 + save: + json: + protocol_id: data.id + + - name: Create run from protocol + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterValues: + sample_count: 4 + volume: 10.23 + dry_run: True + pipette: flex_8channel_50 + response: + status_code: 201 + save: + json: + run_id: data.id + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: idle + current: True + actions: [] + errors: [] + pipettes: [] + modules: [] + labware: [] + labwareOffsets: [] + runTimeParameters: [] + liquids: [] + protocolId: '{protocol_id}' + + - name: Play the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: play + response: + status_code: 201 + json: + data: + id: !anystr + actionType: play + createdAt: !anystr + + - name: Wait for the protocol to complete + max_retries: 10 + delay_after: 0.1 + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + status: succeeded + + - name: Verify the run contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: True + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' + + - name: Mark the run as not-current + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: PATCH + json: + data: + current: False + response: + status_code: 200 + + - name: Verify the archived run still contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: False + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index eaa2824ce75..5f3c45adcaa 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -56,6 +56,7 @@ state_summary VARCHAR, engine_status VARCHAR, _updated_at DATETIME, + run_time_parameters VARCHAR, PRIMARY KEY (id), FOREIGN KEY(protocol_id) REFERENCES protocol (id) ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index 5bf5778c486..a844cdcc6d5 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -11,6 +11,7 @@ commands as pe_commands, errors as pe_errors, ) +from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter from opentrons.protocol_runner import RunResult, JsonRunner, PythonAndLegacyRunner from robot_server.service.task_runner import TaskRunner @@ -60,6 +61,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def protocol_commands() -> List[pe_commands.Command]: """Get a StateSummary value object.""" @@ -122,6 +136,7 @@ async def test_create_play_action_to_start( mock_run_store: RunStore, mock_task_runner: TaskRunner, engine_state_summary: StateSummary, + run_time_parameters: List[RunTimeParameter], protocol_commands: List[pe_commands.Command], run_id: str, subject: RunController, @@ -153,7 +168,7 @@ async def test_create_play_action_to_start( RunResult( commands=protocol_commands, state_summary=engine_state_summary, - parameters=[], + parameters=run_time_parameters, ) ) @@ -164,6 +179,7 @@ async def test_create_play_action_to_start( run_id=run_id, summary=engine_state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ), times=1, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index ba4ceec8799..547ec0a7b74 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -1,5 +1,5 @@ """Tests for RunDataManager.""" -from typing import Optional +from typing import Optional, List import pytest from datetime import datetime @@ -85,6 +85,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def run_resource() -> RunResource: """Get a StateSummary value object.""" @@ -299,6 +312,7 @@ async def test_get_current_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get the current run from the engine.""" @@ -309,6 +323,9 @@ async def test_get_current_run( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) result = subject.get(run_id=run_id) @@ -325,6 +342,7 @@ async def test_get_current_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) assert subject.current_run_id == run_id @@ -335,6 +353,7 @@ async def test_get_historical_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get a historical run from the store.""" @@ -344,6 +363,9 @@ async def test_get_historical_run( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( engine_state_summary ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -361,6 +383,7 @@ async def test_get_historical_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -370,6 +393,7 @@ async def test_get_historical_run_no_data( mock_run_store: RunStore, subject: RunDataManager, run_resource: RunResource, + run_time_parameters: List[pe_types.RunTimeParameter], ) -> None: """It should get a historical run from the store.""" run_id = "hello world" @@ -380,6 +404,9 @@ async def test_get_historical_run_no_data( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( BadStateSummary(dataError=state_exc) ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -398,6 +425,7 @@ async def test_get_historical_run_no_data( pipettes=[], modules=[], liquids=[], + runTimeParameters=run_time_parameters, ) @@ -417,6 +445,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="current-module-id")], # type: ignore[call-arg] liquids=[Liquid(id="some-liquid-id", displayName="liquid", description="desc")], ) + current_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Current Bool", + variableName="current bool", + value=False, + default=True, + ) + ] historical_run_data = StateSummary( status=EngineStatus.STOPPED, @@ -427,6 +463,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="old-module-id")], # type: ignore[call-arg] liquids=[], ) + historical_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Old Bool", + variableName="Old bool", + value=True, + default=False, + ) + ] current_run_resource = RunResource( ok=True, @@ -448,9 +492,15 @@ async def test_get_all_runs( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( current_run_data ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + current_run_time_parameters + ) decoy.when(mock_run_store.get_state_summary("historical-run")).then_return( historical_run_data ) + decoy.when(mock_run_store.get_run_time_parameters("historical-run")).then_return( + historical_run_time_parameters + ) decoy.when(mock_run_store.get_all(length=20)).then_return( [historical_run_resource, current_run_resource] ) @@ -471,6 +521,7 @@ async def test_get_all_runs( pipettes=historical_run_data.pipettes, modules=historical_run_data.modules, liquids=historical_run_data.liquids, + runTimeParameters=historical_run_time_parameters, ), Run( current=True, @@ -485,6 +536,7 @@ async def test_get_all_runs( pipettes=current_run_data.pipettes, modules=current_run_data.modules, liquids=current_run_data.liquids, + runTimeParameters=current_run_time_parameters, ), ] @@ -526,6 +578,7 @@ async def test_delete_historical_run( async def test_update_current( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -537,7 +590,9 @@ async def test_update_current( decoy.when(mock_engine_store.current_run_id).then_return(run_id) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -546,6 +601,7 @@ async def test_update_current( run_id=run_id, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ).then_return(run_resource) @@ -564,6 +620,7 @@ async def test_update_current( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -571,6 +628,7 @@ async def test_update_current( async def test_update_current_noop( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -584,6 +642,9 @@ async def test_update_current_noop( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) decoy.when(mock_run_store.get(run_id=run_id)).then_return(run_resource) result = await subject.update(run_id=run_id, current=current) @@ -594,6 +655,7 @@ async def test_update_current_noop( run_id=run_id, summary=matchers.Anything(), commands=matchers.Anything(), + run_time_parameters=matchers.Anything(), ), times=0, ) @@ -611,6 +673,7 @@ async def test_update_current_noop( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -634,6 +697,7 @@ async def test_update_current_not_allowed( async def test_create_archives_existing( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -647,7 +711,9 @@ async def test_create_archives_existing( decoy.when(mock_engine_store.current_run_id).then_return(run_id_old) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -685,6 +751,7 @@ async def test_create_archives_existing( run_id=run_id_old, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 31cabbe56bd..c6108cf5407 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -120,6 +120,41 @@ def state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name 1", + variableName="variable_name_1", + value=False, + default=True, + ), + pe_types.NumberParameter( + displayName="Display Name 2", + variableName="variable_name_2", + type="int", + min=123.0, + max=456.0, + value=333.0, + default=222.0, + ), + pe_types.EnumParameter( + displayName="Display Name 3", + variableName="variable_name_3", + type="str", + choices=[ + pe_types.EnumChoice( + displayName="Choice Name", + value="cool choice", + ) + ], + default="cooler choice", + value="coolest choice", + ), + ] + + @pytest.fixture def invalid_state_summary() -> StateSummary: """Should fail pydantic validation.""" @@ -164,6 +199,7 @@ def test_update_run_state( subject: RunStore, state_summary: StateSummary, protocol_commands: List[pe_commands.Command], + run_time_parameters: List[pe_types.RunTimeParameter], mock_runs_publisher: mock.Mock, ) -> None: """It should be able to update a run state to the store.""" @@ -184,8 +220,10 @@ def test_update_run_state( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ) run_summary_result = subject.get_state_summary(run_id="run-id") + parameters_result = subject.get_run_time_parameters(run_id="run-id") commands_result = subject.get_commands_slice( run_id="run-id", length=len(protocol_commands), @@ -200,6 +238,7 @@ def test_update_run_state( actions=[action], ) assert run_summary_result == state_summary + assert parameters_result == run_time_parameters assert commands_result.commands == protocol_commands mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( run_id="run-id" @@ -217,6 +256,7 @@ def test_update_state_run_not_found( run_id="run-not-found", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) @@ -436,7 +476,9 @@ def test_get_state_summary( protocol_id=None, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) - subject.update_run_state(run_id="run-id", summary=state_summary, commands=[]) + subject.update_run_state( + run_id="run-id", summary=state_summary, commands=[], run_time_parameters=[] + ) result = subject.get_state_summary(run_id="run-id") assert result == state_summary mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( @@ -454,7 +496,10 @@ def test_get_state_summary_failure( created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) subject.update_run_state( - run_id="run-id", summary=invalid_state_summary, commands=[] + run_id="run-id", + summary=invalid_state_summary, + commands=[], + run_time_parameters=[], ) result = subject.get_state_summary(run_id="run-id") assert isinstance(result, BadStateSummary) @@ -473,6 +518,62 @@ def test_get_state_summary_none(subject: RunStore) -> None: assert result.dataError.code == ErrorCodes.INVALID_STORED_DATA +def test_get_run_time_parameters( + subject: RunStore, + state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: + """It should be able to get store run time parameters.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=run_time_parameters, + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == run_time_parameters + + +def test_get_run_time_parameters_invalid( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there invalid parameters.""" + bad_parameters = [pe_types.BooleanParameter.construct(foo="bar")] # type: ignore[call-arg] + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=bad_parameters, # type: ignore[arg-type] + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + +def test_get_run_time_parameters_none( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there are no run time parameters associated.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + def test_has_run_id(subject: RunStore) -> None: """It should tell us if a given ID is in the store.""" subject.insert( @@ -503,6 +604,7 @@ def test_get_command( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_command(run_id="run-id", command_id="pause-2") @@ -532,6 +634,7 @@ def test_get_command_raise_exception( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) with pytest.raises(expected_exception): subject.get_command(run_id=input_run_id, command_id=input_command_id) @@ -552,6 +655,7 @@ def test_get_command_slice( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=0, length=len(protocol_commands) @@ -598,6 +702,7 @@ def test_get_commands_slice_clamping( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=input_cursor, length=input_length From 80222d9b5c0846c585231957da234a61e97980c2 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 12 Apr 2024 10:13:52 -0400 Subject: [PATCH 44/49] feat(app): add generic run paused splash screen (#14873) Closes EXEC-387 --- .../localization/en/error_recovery.json | 3 + app/src/assets/localization/en/index.ts | 2 + .../RunningProtocol/RunPausedSplash.tsx | 94 +++++++ .../__tests__/RunPausedSplash.test.tsx | 51 ++++ .../__tests__/RunningProtocol.test.tsx | 18 ++ app/src/pages/RunningProtocol/index.tsx | 237 ++++++++++-------- 6 files changed, 302 insertions(+), 103 deletions(-) create mode 100644 app/src/assets/localization/en/error_recovery.json create mode 100644 app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx create mode 100644 app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json new file mode 100644 index 00000000000..7531853df16 --- /dev/null +++ b/app/src/assets/localization/en/error_recovery.json @@ -0,0 +1,3 @@ +{ + "run_paused": "Run paused" +} diff --git a/app/src/assets/localization/en/index.ts b/app/src/assets/localization/en/index.ts index c7256b1d415..8c865445056 100644 --- a/app/src/assets/localization/en/index.ts +++ b/app/src/assets/localization/en/index.ts @@ -29,6 +29,7 @@ import robot_controls from './robot_controls.json' import robot_info from './robot_info.json' import run_details from './run_details.json' import top_navigation from './top_navigation.json' +import error_recovery from './error_recovery.json' export const en = { shared, @@ -62,4 +63,5 @@ export const en = { robot_info, run_details, top_navigation, + error_recovery, } diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx new file mode 100644 index 00000000000..529e5b6653f --- /dev/null +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' + +import { + Flex, + Btn, + Icon, + JUSTIFY_CENTER, + ALIGN_CENTER, + SPACING, + COLORS, + DIRECTION_COLUMN, + POSITION_ABSOLUTE, + TYPOGRAPHY, + OVERFLOW_WRAP_BREAK_WORD, + DISPLAY_FLEX, +} from '@opentrons/components' + +interface RunPausedSplashProps { + onClose: () => void + errorType?: string + protocolName?: string +} + +export function RunPausedSplash({ + onClose, + errorType, + protocolName, +}: RunPausedSplashProps): JSX.Element { + const { t } = useTranslation('error_recovery') + + let subText: string | null + switch (errorType) { + default: + subText = protocolName ?? null + } + + return ( + + + + + {t('run_paused')} + + + {subText} + + + + ) +} + +const SplashHeader = styled.h1` + font-weight: ${TYPOGRAPHY.fontWeightBold}; + text-align: ${TYPOGRAPHY.textAlignLeft}; + font-size: ${TYPOGRAPHY.fontSize80}; + line-height: ${TYPOGRAPHY.lineHeight96}; + color: ${COLORS.white}; +` +const SplashBody = styled.h4` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + overflow-wrap: ${OVERFLOW_WRAP_BREAK_WORD}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; + text-align: ${TYPOGRAPHY.textAlignCenter}; + text-transform: ${TYPOGRAPHY.textTransformCapitalize}; + font-size: ${TYPOGRAPHY.fontSize32}; + line-height: ${TYPOGRAPHY.lineHeight42}; + color: ${COLORS.white}; +` + +const SplashFrame = styled(Flex)` + width: 100%; + height: 100%; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_CENTER}; + grid-gap: ${SPACING.spacing40}; +` diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx new file mode 100644 index 00000000000..6a7061346a4 --- /dev/null +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../i18n' + +import { COLORS } from '@opentrons/components' + +import { RunPausedSplash } from '../RunPausedSplash' + +const render = (props: React.ComponentProps) => { + return renderWithProviders( + + + , + { + i18nInstance: i18n, + } + ) +} + +const MOCK_PROTOCOL_NAME = 'MOCK_PROTOCOL' + +describe('ConfirmCancelRunModal', () => { + let props: React.ComponentProps + const mockOnClose = vi.fn() + + beforeEach(() => { + props = { + onClose: mockOnClose, + protocolName: MOCK_PROTOCOL_NAME, + errorType: '', + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render a generic paused screen if there is no errorType', () => { + render(props) + expect(screen.getByText('Run paused')).toBeInTheDocument() + expect(screen.getByText(MOCK_PROTOCOL_NAME)) + expect(screen.getByRole('button')).toHaveStyle({ + 'background-color': COLORS.grey50, + }) + fireEvent.click(screen.getByRole('button')) + expect(mockOnClose).toHaveBeenCalled() + }) +}) diff --git a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx index 32f87a8047c..2b43991a88f 100644 --- a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -7,6 +7,7 @@ import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_IDLE, RUN_STATUS_STOP_REQUESTED, + RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' import { useAllCommandsQuery, @@ -30,12 +31,14 @@ import { getLocalRobot } from '../../../redux/discovery' import { CancelingRunModal } from '../../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal' import { useTrackProtocolRunEvent } from '../../../organisms/Devices/hooks' import { useMostRecentCompletedAnalysis } from '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { RunPausedSplash } from '../../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash' import { OpenDoorAlertModal } from '../../../organisms/OpenDoorAlertModal' import { RunningProtocol } from '..' import { useNotifyLastRunCommandKey, useNotifyRunQuery, } from '../../../resources/runs' +import { useFeatureFlag } from '../../../redux/config' import type { UseQueryResult } from 'react-query' import type { ProtocolAnalyses } from '@opentrons/api-client' @@ -47,12 +50,15 @@ vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' ) +vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol') vi.mock('../../../redux/discovery') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal') vi.mock('../../../organisms/OpenDoorAlertModal') vi.mock('../../../resources/runs') +vi.mock('../../../redux/config') + const RUN_ID = 'run_id' const ROBOT_NAME = 'otie' const PROTOCOL_ID = 'protocol_id' @@ -85,6 +91,7 @@ describe('RunningProtocol', () => { data: { id: RUN_ID, protocolId: PROTOCOL_ID, + errors: [], }, }, } as any) @@ -133,6 +140,9 @@ describe('RunningProtocol', () => { vi.mocked(useNotifyLastRunCommandKey).mockReturnValue({ data: {}, } as any) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableRunNotes') + .thenReturn(true) }) afterEach(() => { @@ -166,6 +176,14 @@ describe('RunningProtocol', () => { expect(vi.mocked(OpenDoorAlertModal)).toHaveBeenCalled() }) + it(`should display a Run Paused splash screen if the run status is "${RUN_STATUS_AWAITING_RECOVERY}"`, () => { + when(vi.mocked(useRunStatus)) + .calledWith(RUN_ID, { refetchInterval: 5000 }) + .thenReturn(RUN_STATUS_AWAITING_RECOVERY) + render(`/runs/${RUN_ID}/run`) + expect(vi.mocked(RunPausedSplash)).toHaveBeenCalled() + }) + // ToDo (kj:04/04/2023) need to figure out the way to simulate swipe it.todo('should render RunningProtocolCommandList when swiping left') // const [{ getByText }] = render(`/runs/${RUN_ID}/run`) diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index 2fc56806679..3ebe3b1c0ab 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -25,8 +25,10 @@ import { import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' +import { useFeatureFlag } from '../../redux/config' import { StepMeter } from '../../atoms/StepMeter' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { @@ -51,6 +53,7 @@ import { } from '../../organisms/Devices/hooks' import { CancelingRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal' import { ConfirmCancelRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal' +import { RunPausedSplash } from '../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash' import { getLocalRobot } from '../../redux/discovery' import { OpenDoorAlertModal } from '../../organisms/OpenDoorAlertModal' @@ -102,6 +105,7 @@ export function RunningProtocol(): JSX.Element { const runStatus = useRunStatus(runId, { refetchInterval: RUN_STATUS_REFETCH_INTERVAL, }) + const [enableSplash, setEnableSplash] = React.useState(true) const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const protocolId = runRecord?.data.protocolId ?? null @@ -117,6 +121,10 @@ export function RunningProtocol(): JSX.Element { const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const robotAnalyticsData = useRobotAnalyticsData(robotName) const robotType = useRobotType(robotName) + const errorType = runRecord?.data.errors[0]?.errorType + + const enableRunNotes = useFeatureFlag('enableRunNotes') + React.useEffect(() => { if ( currentOption === 'CurrentRunningProtocolCommand' && @@ -160,114 +168,137 @@ export function RunningProtocol(): JSX.Element { return ( <> - {runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR ? ( - - ) : null} - {runStatus === RUN_STATUS_STOP_REQUESTED ? : null} - - {robotSideAnalysis != null ? ( - - ) : null} - {showConfirmCancelRunModal ? ( - - ) : null} - {interventionModalCommandKey != null && - runRecord?.data != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) ? ( - - ) : null} - - {robotSideAnalysis != null ? ( - currentOption === 'CurrentRunningProtocolCommand' ? ( - - (lastAnimatedCommand.current = newCommandKey) + {enableSplash && + runStatus === RUN_STATUS_AWAITING_RECOVERY && + enableRunNotes ? ( + setEnableSplash(false)} + errorType={errorType} + protocolName={protocolName} + /> + ) : ( + <> + {runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR ? ( + + ) : null} + {runStatus === RUN_STATUS_STOP_REQUESTED ? ( + + ) : null} + + {robotSideAnalysis != null ? ( + - ) : ( - <> - + ) : null} + {interventionModalCommandKey != null && + runRecord?.data != null && + lastRunCommand != null && + isInterventionCommand(lastRunCommand) ? ( + + ) : null} + + {robotSideAnalysis != null ? ( + currentOption === 'CurrentRunningProtocolCommand' ? ( + + (lastAnimatedCommand.current = newCommandKey) + } + /> + ) : ( + <> + + + + ) + ) : ( + + )} + + - - - ) - ) : ( - - )} - - - + + - - + + )} ) } From fd6a5b2a03e221f2f1c1d3df9cf0ce056ec0e53f Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 12 Apr 2024 10:22:00 -0400 Subject: [PATCH 45/49] fix(app): fix hepa/uv firmware copy (#14881) Closes RQA-2561 --- api-client/src/subsystems/types.ts | 1 + app/src/assets/localization/en/firmware_update.json | 1 + .../__tests__/UpdateInProgressModal.test.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api-client/src/subsystems/types.ts b/api-client/src/subsystems/types.ts index 14f45324f62..564d59b21b2 100644 --- a/api-client/src/subsystems/types.ts +++ b/api-client/src/subsystems/types.ts @@ -6,6 +6,7 @@ export type Subsystem = | 'pipette_right' | 'gripper' | 'rear_panel' + | 'hepa_uv' type UpdateStatus = 'queued' | 'updating' | 'done' export interface SubsystemUpdateProgressData { diff --git a/app/src/assets/localization/en/firmware_update.json b/app/src/assets/localization/en/firmware_update.json index 8abe122d914..0540963084b 100644 --- a/app/src/assets/localization/en/firmware_update.json +++ b/app/src/assets/localization/en/firmware_update.json @@ -5,6 +5,7 @@ "gantry_y": "Gantry Y", "gripper": "Gripper", "head": "Head", + "hepa_uv": "HEPA/UV Module", "pipette_left": "pipette", "pipette_right": "pipette", "ready_to_use": "Your {{instrument}} is ready to use!", diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx index 818b8ce341e..c08bef6b4ea 100644 --- a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx @@ -18,8 +18,15 @@ describe('UpdateInProgressModal', () => { subsystem: 'pipette_right', } }) - it('renders text', () => { + it('renders pipette text', () => { const { getByText } = render(props) getByText('Updating pipette firmware...') }) + it('renders Hepa/UV text', () => { + props = { + subsystem: 'hepa_uv', + } + const { getByText } = render(props) + getByText('Updating HEPA/UV Module firmware...') + }) }) From 15782add75b00d8aa26584e5d6bbce1ed2576df7 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Fri, 12 Apr 2024 10:23:14 -0400 Subject: [PATCH 46/49] refactor(protocol-engine): Rename stop() and pause() -> request_stop() and request_pause() (#14879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview This fixes something that keeps confusing me as I work on EXEC-382. Various things state that `ProtocolEngine.stop()` takes effect immediately—meaning, to me, that the robot's motion is stopped immediately, the protocol exits immediately, and the HTTP run is marked as `stopped` immediately. This does not seem true. It merely puts the run into a `stop-requested` state, which only later settles into a `stopped` state. This PR adjusts some docstrings and renames `stop()` to ~~`stop_soon()`~~ `request_stop()`. ~~The name `stop_soon()` is inspired by asyncio and anyio's `call_soon()`.~~ `pause()` has the same caveat, so it's renamed to `request_pause()` for consistency. # Test plan None needed. # Review requests * ~~Taking for granted, for a moment, that the `ProtocolEngine` interface has to work like this: is `stop_soon()` a good name? Maybe `request_stop()` would be better?~~ Done. * ~~`pause()` has the same caveat. Do we want to rename that too, for consistency?~~ Done. # Risk assessment No risk. --- .../protocol_engine/actions/actions.py | 5 +---- .../protocol_engine/protocol_engine.py | 21 +++++++++++++------ .../protocol_runner/protocol_runner.py | 4 ++-- .../protocol_engine/test_protocol_engine.py | 6 +++--- .../protocol_runner/test_protocol_runner.py | 4 ++-- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index ee36e76f7de..2d46f614ec3 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -55,10 +55,7 @@ class PauseAction: @dataclass(frozen=True) class StopAction: - """Stop the current engine execution. - - After a StopAction, the engine status will be marked as stopped. - """ + """Request engine execution to stop soon.""" from_estop: bool = False diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index bd995f4339a..7389078343d 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -159,8 +159,12 @@ def play(self, deck_configuration: Optional[DeckConfigurationType] = None) -> No else: self._hardware_api.resume(HardwarePauseType.PAUSE) - def pause(self) -> None: - """Pause executing commands in the queue.""" + def request_pause(self) -> None: + """Make command execution pause soon. + + This will try to pause in the middle of the ongoing command, if there is one. + Otherwise, whenever the next command begins, the pause will happen then. + """ action = self._state_store.commands.validate_action_allowed( PauseAction(source=PauseSource.CLIENT) ) @@ -371,12 +375,17 @@ def estop( else: _log.info("estop pressed before protocol was started, taking no action.") - async def stop(self) -> None: - """Stop execution immediately, halting all motion and cancelling future commands. + async def request_stop(self) -> None: + """Make command execution stop soon. + + This will try to interrupt the ongoing command, if there is one. Future commands + are canceled. However, by the time this method returns, things may not have + settled by the time this method returns; the last command may still be + running. - After an engine has been `stop`'ed, it cannot be restarted. + After a stop has been requested, the engine cannot be restarted. - After a `stop`, you must still call `finish` to give the engine a chance + After a stop request, you must still call `finish` to give the engine a chance to clean up resources and propagate errors. """ action = self._state_store.commands.validate_action_allowed(StopAction()) diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index a1e88969615..9c097bbba2d 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -101,12 +101,12 @@ def play(self, deck_configuration: Optional[DeckConfigurationType] = None) -> No def pause(self) -> None: """Pause the run.""" - self._protocol_engine.pause() + self._protocol_engine.request_pause() async def stop(self) -> None: """Stop (cancel) the run.""" if self.was_started(): - await self._protocol_engine.stop() + await self._protocol_engine.request_stop() else: await self._protocol_engine.finish( drop_tips_after_run=False, diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index dd96b8d968a..959c9172b9e 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -515,7 +515,7 @@ def test_pause( state_store.commands.validate_action_allowed(expected_action), ).then_return(expected_action) - subject.pause() + subject.request_pause() decoy.verify( action_dispatcher.dispatch(expected_action), @@ -810,7 +810,7 @@ async def test_stop( state_store.commands.validate_action_allowed(expected_action), ).then_return(expected_action) - await subject.stop() + await subject.request_stop() decoy.verify( action_dispatcher.dispatch(expected_action), @@ -836,7 +836,7 @@ async def test_stop_for_legacy_core_protocols( decoy.when(hardware_api.is_movement_execution_taskified()).then_return(True) - await subject.stop() + await subject.request_stop() decoy.verify( action_dispatcher.dispatch(expected_action), diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 5497e9e12ab..68e215bf3dd 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -238,7 +238,7 @@ def test_pause( """It should pause a protocol run with pause.""" subject.pause() - decoy.verify(protocol_engine.pause(), times=1) + decoy.verify(protocol_engine.request_pause(), times=1) @pytest.mark.parametrize( @@ -261,7 +261,7 @@ async def test_stop( subject.play() await subject.stop() - decoy.verify(await protocol_engine.stop(), times=1) + decoy.verify(await protocol_engine.request_stop(), times=1) @pytest.mark.parametrize( From aba93f180a0750d69ff6667abae3f86026ae62c2 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 12 Apr 2024 11:06:27 -0400 Subject: [PATCH 47/49] feat(opentron-ai-client): add Side Panel component (#14886) * feat(opentron-ai-client): add Side Panel component --- .storybook/main.js | 1 + .storybook/preview.jsx | 2 +- components/src/styles/flexbox.ts | 1 + opentrons-ai-client/README.md | 5 +- .../src/assets/images/opentrons_logo.svg | 51 +++++++++ .../localization/en/protocol_generator.json | 10 +- opentrons-ai-client/src/main.tsx | 9 +- .../molecules/SidePanel/SidePanel.stories.tsx | 21 ++++ .../SidePanel/__tests__/SidePanel.test.tsx | 48 ++++++++ .../src/molecules/SidePanel/index.tsx | 103 ++++++++++++++++++ opentrons-ai-client/src/molecules/index.ts | 1 + 11 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 opentrons-ai-client/src/assets/images/opentrons_logo.svg create mode 100644 opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx create mode 100644 opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx create mode 100644 opentrons-ai-client/src/molecules/SidePanel/index.tsx create mode 100644 opentrons-ai-client/src/molecules/index.ts diff --git a/.storybook/main.js b/.storybook/main.js index e9fc91cdf48..985486d5d4e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -2,6 +2,7 @@ module.exports = { stories: [ '../components/**/*.stories.@(js|jsx|ts|tsx)', '../app/**/*.stories.@(js|jsx|ts|tsx)', + '../opentrons-ai-client/**/*.stories.@(js|jsx|ts|tsx)', ], addons: [ diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index d8537e57827..32864c9abcb 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -20,7 +20,7 @@ export const parameters = { options: { storySort: { method: 'alphabetical', - order: ['Design Tokens', 'Library', 'App', 'ODD'], + order: ['Design Tokens', 'Library', 'App', 'ODD', 'AI'], }, }, } diff --git a/components/src/styles/flexbox.ts b/components/src/styles/flexbox.ts index bc588372e96..2c36936b200 100644 --- a/components/src/styles/flexbox.ts +++ b/components/src/styles/flexbox.ts @@ -1,6 +1,7 @@ export const FLEX_NONE = 'none' export const FLEX_AUTO = 'auto' export const FLEX_MIN_CONTENT = 'min-content' +export const FLEX_MAX_CONTENT = 'max-content' export const ALIGN_NORMAL = 'normal' export const ALIGN_START = 'start' diff --git a/opentrons-ai-client/README.md b/opentrons-ai-client/README.md index c2ff2908418..d4c80c2bb23 100644 --- a/opentrons-ai-client/README.md +++ b/opentrons-ai-client/README.md @@ -2,8 +2,6 @@ [![JavaScript Style Guide][style-guide-badge]][style-guide] -[Download][] | [Support][] - ## Overview The Opentrons AI application helps you to create a protocol with natural language. @@ -31,7 +29,7 @@ The UI stack is built using: Some important directories: -- `opentrons-ai-server` — Opentrons AI application's server +- [opentrons-ai-server][] — Opentrons AI application's server ## Copy management @@ -62,3 +60,4 @@ TBD [babel]: https://babeljs.io/ [vite]: https://vitejs.dev/ [bundle-analyzer]: https://github.com/webpack-contrib/webpack-bundle-analyzer +[opentrons-ai-server]: https://github.com/Opentrons/opentrons/tree/edge/opentrons-ai-server diff --git a/opentrons-ai-client/src/assets/images/opentrons_logo.svg b/opentrons-ai-client/src/assets/images/opentrons_logo.svg new file mode 100644 index 00000000000..b183d161e81 --- /dev/null +++ b/opentrons-ai-client/src/assets/images/opentrons_logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index c8ac35504bb..f19455ad47e 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -2,20 +2,22 @@ "api": "API: An API level is 2.15", "application": "Application: Your protocol's name, describing what it does.", "commands": "Commands: List the protocol's steps, specifying quantities in microliters and giving exact source and destination locations.", + "got_feedback": "Got feedback? We love to hear it.", "make_sure_your_prompt": "Make sure your prompt includes the following:", "metadata": "Metadata: Three pieces of information.", "modules": "Modules: Thermocycler or Temperature Module.", "opentronsai_asks_you": "OpentronsAI asks you to provide it!", "ot2_pipettes": "OT-2 pipettes: Include volume, number of channels, and generation.", - "prc_flex": "PRC (Flex)", + "prc_flex": "PCR (Flex)", "prc": "PCR", "reagent_transfer_flex": "Reagent Transfer (Flex)", "reagent_transfer": "Reagent Transfer", "robot": "Robot: OT-2.", - "sidebar_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", - "sidebar_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", - "stuck": "Stuck? Try these example prompts to get started.", + "share_your_thoughts": "Share your thoughts here", + "side_panel_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", + "side_panel_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", "tipracks_and_labware": "Tip racks and labware: Use names from the Opentrons Labware Library.", + "try_example_prompts": "Stuck? Try these example prompts to get started.", "type_your_prompt": "Type your prompt...", "well_allocations": "Well allocations: Describe where liquids should go in labware.", "what_if_you": "What if you don’t provide all of those pieces of information?", diff --git a/opentrons-ai-client/src/main.tsx b/opentrons-ai-client/src/main.tsx index 466bd35e081..bf46623695e 100644 --- a/opentrons-ai-client/src/main.tsx +++ b/opentrons-ai-client/src/main.tsx @@ -1,12 +1,17 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { I18nextProvider } from 'react-i18next' + +import { i18n } from './i18n' import { App } from './App' const rootElement = document.getElementById('root') -if (rootElement) { +if (rootElement != null) { ReactDOM.createRoot(rootElement).render( - + + + ) } else { diff --git a/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx b/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx new file mode 100644 index 00000000000..1c1d30b7548 --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { i18n } from '../../i18n' +import { SidePanel as SidePanelComponent } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'AI/molecules/SidePanel', + component: SidePanelComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta +type Story = StoryObj +export const SidePanel: Story = {} diff --git a/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx b/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx new file mode 100644 index 00000000000..56cb50f73fc --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { SidePanel } from '../index' + +const LOGO_FILE_NAME = + '/opentrons-ai-client/src/assets/images/opentrons_logo.svg' + +const FEEDBACK_FORM_LINK = 'https://opentrons-ai-beta.paperform.co/' + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SidePanel', () => { + it('should render logo and text', () => { + render() + const image = screen.getByRole('img') + expect(image.getAttribute('src')).toEqual(LOGO_FILE_NAME) + screen.getByText( + 'Use natural language to generate protocols with OpentronsAI powered by OpenAI' + ) + screen.getByText( + 'Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.' + ) + screen.getByText('Stuck? Try these example prompts to get started.') + screen.getByText('Got feedback? We love to hear it.') + const link = screen.getByRole('link', { + name: 'Share your thoughts here', + }) + expect(link).toHaveAttribute('href', FEEDBACK_FORM_LINK) + }) + + it('should render buttons', () => { + render() + screen.getByRole('button', { name: 'PCR' }) + screen.getByRole('button', { name: 'PCR (Flex)' }) + screen.getByRole('button', { name: 'Reagent Transfer' }) + screen.getByRole('button', { name: 'Reagent Transfer (Flex)' }) + }) + it.todo('should call a mock function when clicking a button') +}) diff --git a/opentrons-ai-client/src/molecules/SidePanel/index.tsx b/opentrons-ai-client/src/molecules/SidePanel/index.tsx new file mode 100644 index 00000000000..536c0709a8b --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/index.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + Link, + PrimaryButton, + SPACING, + StyledText, + TYPOGRAPHY, + WRAP, +} from '@opentrons/components' +import LOGO_PATH from '../../assets/images/opentrons_logo.svg' + +const IMAGE_ALT = 'Opentrons logo' +const FEEDBACK_FORM_LINK = 'https://opentrons-ai-beta.paperform.co/' +export function SidePanel(): JSX.Element { + const { t } = useTranslation('protocol_generator') + return ( + + {/* logo */} + + {IMAGE_ALT} + + + {/* body text */} + + + {t('side_panel_header')} + + {t('side_panel_body')} + + + {/* buttons */} + + + {t('try_example_prompts')} + + + + {/* ToDo(kk:04/11/2024) add a button component */} + {t('reagent_transfer')} + {t('reagent_transfer_flex')} + {t('prc')} + {t('prc_flex')} + + + + + {t('got_feedback')} + + + {t('share_your_thoughts')} + + + + ) +} + +const HEADER_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize32}; + line-height: ${TYPOGRAPHY.lineHeight42}; + font-weight: ${TYPOGRAPHY.fontWeightBold}; + color: ${COLORS.white}; +` +const BODY_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + color: ${COLORS.white}; +` +const BUTTON_GUIDE_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; + color: ${COLORS.white}; +` + +const PromptButton = styled(PrimaryButton)` + border-radius: 2rem; + white-space: nowrap; +` + +const FeedbackLink = styled(Link)` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightBold}; + color: ${COLORS.white}; + text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; +` diff --git a/opentrons-ai-client/src/molecules/index.ts b/opentrons-ai-client/src/molecules/index.ts new file mode 100644 index 00000000000..80fcd68f91a --- /dev/null +++ b/opentrons-ai-client/src/molecules/index.ts @@ -0,0 +1 @@ +export * from './SidePanel' From b0fb14f139c59cefa8c1e3d9319279a3f5ca6fb4 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 12 Apr 2024 12:34:59 -0400 Subject: [PATCH 48/49] fix(app): update software keyboard ref type (#14860) * fix(app): update software keyboard ref type --- app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx | 3 ++- app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx | 3 ++- app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx | 4 ++-- app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx | 4 +++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx index 5698e49f1e6..dccad085c08 100644 --- a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import Keyboard from 'react-simple-keyboard' import { alphanumericKeyboardLayout, customDisplay } from '../constants' +import type { KeyboardReactInterface } from 'react-simple-keyboard' import '../index.css' import './index.css' @@ -8,7 +9,7 @@ import './index.css' // TODO (kk:04/05/2024) add debug to make debugging easy interface AlphanumericKeyboardProps { onChange: (input: string) => void - keyboardRef: React.MutableRefObject + keyboardRef: React.MutableRefObject debug?: boolean } diff --git a/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx index 69c5c748d3a..663efdd9c24 100644 --- a/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { KeyboardReact as Keyboard } from 'react-simple-keyboard' import { customDisplay, fullKeyboardLayout } from '../constants' +import type { KeyboardReactInterface } from 'react-simple-keyboard' import '../index.css' import './index.css' @@ -8,7 +9,7 @@ import './index.css' // TODO (kk:04/05/2024) add debug to make debugging easy interface FullKeyboardProps { onChange: (input: string) => void - keyboardRef: React.MutableRefObject + keyboardRef: React.MutableRefObject debug?: boolean } diff --git a/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx index 9ff8c278423..310008cddc8 100644 --- a/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx +++ b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { KeyboardReact as Keyboard } from 'react-simple-keyboard' - +import type { KeyboardReactInterface } from 'react-simple-keyboard' import '../index.css' import './index.css' @@ -11,7 +11,7 @@ const customDisplay = { // TODO (kk:04/05/2024) add debug to make debugging easy interface IndividualKeyProps { onChange: (input: string) => void - keyboardRef: React.MutableRefObject + keyboardRef: React.MutableRefObject keyText: string debug?: boolean } diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx index 9065bcce44f..8c41120d536 100644 --- a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx @@ -1,13 +1,15 @@ import * as React from 'react' import { KeyboardReact as Keyboard } from 'react-simple-keyboard' import { numericalKeyboardLayout, numericalCustom } from '../constants' + +import type { KeyboardReactInterface } from 'react-simple-keyboard' import '../index.css' import './index.css' // Note (kk:04/05/2024) add debug to make debugging easy interface NumericalKeyboardProps { onChange: (input: string) => void - keyboardRef: React.MutableRefObject + keyboardRef: React.MutableRefObject isDecimal?: boolean hasHyphen?: boolean debug?: boolean From 485bf0e3f258e66f242a6c27adacf6b074e912bc Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Fri, 12 Apr 2024 13:19:11 -0400 Subject: [PATCH 49/49] feat(app, api-client, react-api-client): add api-client method for protocol reanalysis (#14878) closes AUTH-118 --- .../src/protocols/createProtocolAnalysis.ts | 28 ++++++ api-client/src/protocols/index.ts | 1 + .../ProtocolSetupParameters.test.tsx | 16 ++-- .../ProtocolSetupParameters/index.tsx | 25 +++++- app/src/pages/ProtocolDetails/index.tsx | 5 +- app/src/pages/ProtocolSetup/index.tsx | 26 +++--- ...useCreateProtocolAnalysisMutation.test.tsx | 77 +++++++++++++++++ react-api-client/src/protocols/index.ts | 1 + .../useCreateProtocolAnalysisMutation.ts | 86 +++++++++++++++++++ 9 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 api-client/src/protocols/createProtocolAnalysis.ts create mode 100644 react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx create mode 100644 react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts diff --git a/api-client/src/protocols/createProtocolAnalysis.ts b/api-client/src/protocols/createProtocolAnalysis.ts new file mode 100644 index 00000000000..81ab83c11af --- /dev/null +++ b/api-client/src/protocols/createProtocolAnalysis.ts @@ -0,0 +1,28 @@ +import { POST, request } from '../request' + +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { RunTimeParameterCreateData } from '../runs' + +interface CreateProtocolAnalysisData { + runTimeParameterValues: RunTimeParameterCreateData + forceReAnalyze: boolean +} + +export function createProtocolAnalysis( + config: HostConfig, + protocolKey: string, + runTimeParameterValues?: RunTimeParameterCreateData, + forceReAnalyze?: boolean +): ResponsePromise { + const data = { + runTimeParameterValues: runTimeParameterValues ?? {}, + forceReAnalyze: forceReAnalyze ?? false, + } + const response = request< + ProtocolAnalysisSummary[], + { data: CreateProtocolAnalysisData } + >(POST, `/protocols/${protocolKey}/analyses`, { data }, config) + return response +} diff --git a/api-client/src/protocols/index.ts b/api-client/src/protocols/index.ts index 6febd0795cf..f035fa000e1 100644 --- a/api-client/src/protocols/index.ts +++ b/api-client/src/protocols/index.ts @@ -3,6 +3,7 @@ export { getProtocolAnalyses } from './getProtocolAnalyses' export { getProtocolAnalysisAsDocument } from './getProtocolAnalysisAsDocument' export { deleteProtocol } from './deleteProtocol' export { createProtocol } from './createProtocol' +export { createProtocolAnalysis } from './createProtocolAnalysis' export { getProtocols } from './getProtocols' export { getProtocolIds } from './getProtocolIds' diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx index 1dc55314d59..4871eeaa379 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx @@ -2,7 +2,11 @@ import * as React from 'react' import { when } from 'vitest-when' import { it, describe, beforeEach, vi, expect } from 'vitest' import { fireEvent, screen } from '@testing-library/react' -import { useCreateRunMutation, useHost } from '@opentrons/react-api-client' +import { + useCreateProtocolAnalysisMutation, + useCreateRunMutation, + useHost, +} from '@opentrons/react-api-client' import { i18n } from '../../../i18n' import { renderWithProviders } from '../../../__testing-utils__' import { ProtocolSetupParameters } from '..' @@ -24,6 +28,7 @@ vi.mock('react-router-dom', async importOriginal => { } }) const MOCK_HOST_CONFIG: HostConfig = { hostname: 'MOCK_HOST' } +const mockCreateProtocolAnalysis = vi.fn() const mockCreateRun = vi.fn() const render = ( props: React.ComponentProps @@ -43,6 +48,9 @@ describe('ProtocolSetupParameters', () => { } vi.mocked(ChooseEnum).mockReturnValue(
    mock ChooseEnum
    ) vi.mocked(useHost).mockReturnValue(MOCK_HOST_CONFIG) + when(vi.mocked(useCreateProtocolAnalysisMutation)) + .calledWith(expect.anything(), expect.anything()) + .thenReturn({ createProtocolAnalysis: mockCreateProtocolAnalysis } as any) when(vi.mocked(useCreateRunMutation)) .calledWith(expect.anything()) .thenReturn({ createRun: mockCreateRun } as any) @@ -62,10 +70,9 @@ describe('ProtocolSetupParameters', () => { }) it('renders the other setting when boolean param is selected', () => { render(props) - screen.getByText('Off') - expect(screen.getAllByText('On')).toHaveLength(3) + expect(screen.getAllByText('On')).toHaveLength(2) fireEvent.click(screen.getByText('Dry Run')) - expect(screen.getAllByText('On')).toHaveLength(4) + expect(screen.getAllByText('On')).toHaveLength(3) }) it('renders the back icon and calls useHistory', () => { render(props) @@ -88,6 +95,5 @@ describe('ProtocolSetupParameters', () => { const title = screen.getByText('Reset parameter values?') fireEvent.click(screen.getByRole('button', { name: 'Go back' })) expect(title).not.toBeInTheDocument() - // TODO(jr, 3/19/24): wire up the confirm button }) }) diff --git a/app/src/organisms/ProtocolSetupParameters/index.tsx b/app/src/organisms/ProtocolSetupParameters/index.tsx index ac1f3fd700f..5dae07260f6 100644 --- a/app/src/organisms/ProtocolSetupParameters/index.tsx +++ b/app/src/organisms/ProtocolSetupParameters/index.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' -import { useCreateRunMutation, useHost } from '@opentrons/react-api-client' +import { + useCreateProtocolAnalysisMutation, + useCreateRunMutation, + useHost, +} from '@opentrons/react-api-client' import { useQueryClient } from 'react-query' import { ALIGN_CENTER, @@ -51,7 +55,12 @@ export function ProtocolSetupParameters({ const [ runTimeParametersOverrides, setRunTimeParametersOverrides, - ] = React.useState(runTimeParameters) + ] = React.useState( + // present defaults rather than last-set value + runTimeParameters.map(param => { + return { ...param, value: param.default } + }) + ) const updateParameters = ( value: boolean | string | number, @@ -85,6 +94,14 @@ export function ProtocolSetupParameters({ } } + const runTimeParameterValues = getRunTimeParameterValuesForRun( + runTimeParametersOverrides + ) + const { createProtocolAnalysis } = useCreateProtocolAnalysisMutation( + protocolId, + host + ) + const { createRun, isLoading } = useCreateRunMutation({ onSuccess: data => { queryClient @@ -96,6 +113,10 @@ export function ProtocolSetupParameters({ }) const handleConfirmValues = (): void => { setStartSetup(true) + createProtocolAnalysis({ + protocolKey: protocolId, + runTimeParameterValues: runTimeParameterValues, + }) createRun({ protocolId, labwareOffsets, diff --git a/app/src/pages/ProtocolDetails/index.tsx b/app/src/pages/ProtocolDetails/index.tsx index 0503c0eae54..850fd0a8016 100644 --- a/app/src/pages/ProtocolDetails/index.tsx +++ b/app/src/pages/ProtocolDetails/index.tsx @@ -346,13 +346,12 @@ export function ProtocolDetails(): JSX.Element | null { let pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinned = pinnedProtocolIds.includes(protocolId) - const { data: protocolData } = useProtocolQuery(protocolId) const { data: mostRecentAnalysis, } = useProtocolAnalysisAsDocumentQuery( protocolId, - last(protocolData?.data.analysisSummaries)?.id ?? null, - { enabled: protocolData != null } + last(protocolRecord?.data.analysisSummaries)?.id ?? null, + { enabled: protocolRecord != null } ) const shouldApplyOffsets = useSelector(getApplyHistoricOffsets) diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index 97499316f27..14b871f839c 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -69,7 +69,6 @@ import { getProtocolUsesGripper, } from '../../organisms/ProtocolSetupInstruments/utils' import { - useProtocolHasRunTimeParameters, useRunControls, useRunStatus, } from '../../organisms/RunTimeControl/hooks' @@ -257,9 +256,6 @@ function PrepareToRun({ const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const history = useHistory() const { makeSnackbar } = useToaster() - const hasRunTimeParameters = useProtocolHasRunTimeParameters(runId) - console.log(hasRunTimeParameters) - // Watch for scrolling to toggle dropshadow const scrollRef = React.useRef(null) const [isScrolled, setIsScrolled] = React.useState(false) const observer = new IntersectionObserver(([entry]) => { @@ -366,6 +362,12 @@ function PrepareToRun({ }) const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) + const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] + const hasRunTimeParameters = runTimeParameters.length > 0 + const hasCustomRunTimeParameters = runTimeParameters.some( + parameter => parameter.value !== parameter.default + ) + const [ showConfirmCancelModal, setShowConfirmCancelModal, @@ -623,11 +625,11 @@ function PrepareToRun({ doorStatus?.data.status === 'open' && doorStatus?.data.doorRequiredClosedForProtocol - // TODO(Jr, 3/20/24): wire up custom values - const hasCustomValues = false - const parametersDetail = hasCustomValues - ? t('custom_values') - : t('default_values') + const parametersDetail = hasRunTimeParameters + ? hasCustomRunTimeParameters + ? t('custom_values') + : t('default_values') + : t('no_parameters_specified') return ( <> @@ -733,11 +735,7 @@ function PrepareToRun({ setSetupScreen('view only parameters')} title={t('parameters')} - detail={t( - hasRunTimeParameters - ? parametersDetail - : t('no_parameters_specified') - )} + detail={parametersDetail} subDetail={null} status="general" disabled={!hasRunTimeParameters} diff --git a/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx b/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx new file mode 100644 index 00000000000..e04c020fb1d --- /dev/null +++ b/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createProtocolAnalysis } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useCreateProtocolAnalysisMutation } from '..' +import type { HostConfig, Response } from '@opentrons/api-client' +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const ANALYSIS_SUMMARY_RESPONSE = [ + { id: 'fakeAnalysis1', status: 'completed' }, + { id: 'fakeAnalysis2', status: 'pending' }, +] as ProtocolAnalysisSummary[] + +describe('useCreateProtocolAnalysisMutation hook', () => { + let wrapper: React.FunctionComponent<{ children: React.ReactNode }> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{ + children: React.ReactNode + }> = ({ children }) => ( + {children} + ) + wrapper = clientProvider + }) + + it('should return no data when calling createProtocolAnalysis if the request fails', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createProtocolAnalysis).mockRejectedValue('oh no') + + const { result } = renderHook( + () => useCreateProtocolAnalysisMutation('fake-protocol-key'), + { + wrapper, + } + ) + + expect(result.current.data).toBeUndefined() + result.current.createProtocolAnalysis({ + protocolKey: 'fake-protocol-key', + runTimeParameterValues: {}, + }) + await waitFor(() => { + expect(result.current.data).toBeUndefined() + }) + }) + + it('should create an array of ProtocolAnalysisSummaries when calling the createProtocolAnalysis callback', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createProtocolAnalysis).mockResolvedValue({ + data: ANALYSIS_SUMMARY_RESPONSE, + } as Response) + + const { result } = renderHook( + () => useCreateProtocolAnalysisMutation('fake-protocol-key'), + { + wrapper, + } + ) + act(() => + result.current.createProtocolAnalysis({ + protocolKey: 'fake-protocol-key', + runTimeParameterValues: {}, + }) + ) + + await waitFor(() => { + expect(result.current.data).toEqual(ANALYSIS_SUMMARY_RESPONSE) + }) + }) +}) diff --git a/react-api-client/src/protocols/index.ts b/react-api-client/src/protocols/index.ts index ddf7c3eeaac..561dee01e8b 100644 --- a/react-api-client/src/protocols/index.ts +++ b/react-api-client/src/protocols/index.ts @@ -4,4 +4,5 @@ export { useProtocolQuery } from './useProtocolQuery' export { useProtocolAnalysesQuery } from './useProtocolAnalysesQuery' export { useProtocolAnalysisAsDocumentQuery } from './useProtocolAnalysisAsDocumentQuery' export { useCreateProtocolMutation } from './useCreateProtocolMutation' +export { useCreateProtocolAnalysisMutation } from './useCreateProtocolAnalysisMutation' export { useDeleteProtocolMutation } from './useDeleteProtocolMutation' diff --git a/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts b/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts new file mode 100644 index 00000000000..f8ba6e10586 --- /dev/null +++ b/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts @@ -0,0 +1,86 @@ +import { createProtocolAnalysis } from '@opentrons/api-client' +import { useMutation, useQueryClient } from 'react-query' +import { useHost } from '../api' +import type { + ErrorResponse, + HostConfig, + RunTimeParameterCreateData, +} from '@opentrons/api-client' +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' +import type { AxiosError } from 'axios' +import type { + UseMutationResult, + UseMutationOptions, + UseMutateFunction, +} from 'react-query' + +export interface CreateProtocolAnalysisVariables { + protocolKey: string + runTimeParameterValues?: RunTimeParameterCreateData + forceReAnalyze?: boolean +} +export type UseCreateProtocolMutationResult = UseMutationResult< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables +> & { + createProtocolAnalysis: UseMutateFunction< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables + > +} + +export type UseCreateProtocolAnalysisMutationOptions = UseMutationOptions< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables +> + +export function useCreateProtocolAnalysisMutation( + protocolId: string | null, + hostOverride?: HostConfig | null, + options: UseCreateProtocolAnalysisMutationOptions | undefined = {} +): UseCreateProtocolMutationResult { + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost + const queryClient = useQueryClient() + + const mutation = useMutation< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables + >( + [host, 'protocols', protocolId, 'analyses'], + ({ protocolKey, runTimeParameterValues, forceReAnalyze }) => + createProtocolAnalysis( + host as HostConfig, + protocolKey, + runTimeParameterValues, + forceReAnalyze + ) + .then(response => { + queryClient + .invalidateQueries([host, 'protocols', protocolId, 'analyses']) + .then(() => + queryClient.setQueryData( + [host, 'protocols', protocolId, 'analyses'], + response.data + ) + ) + .catch((e: Error) => { + throw e + }) + return response.data + }) + .catch((e: Error) => { + throw e + }), + options + ) + return { + ...mutation, + createProtocolAnalysis: mutation.mutate, + } +}