diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index 7270e5176441..8e90e08190bd 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -248,7 +248,7 @@ async def _do_analyze(protocol_source: ProtocolSource) -> RunResult: protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) except Exception as error: err_id = "analysis-setup-error" diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 975f2996c98a..ed95efca22d4 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -12,6 +12,7 @@ from opentrons.protocols.parameters.exceptions import ( RuntimeParameterRequired as RuntimeParameterRequiredError, ) +from opentrons.protocols.parameters.types import CSVParameter from .protocol_context import ProtocolContext from .deck import Deck @@ -74,6 +75,7 @@ "ALL", "OFF_DECK", "RuntimeParameterRequiredError", + "CSVParameter", # For internal Opentrons use only: "create_protocol_context", "ProtocolEngineCoreRequiredError", diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index 32528dab8d6a..f334c2ef1d25 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -1,5 +1,5 @@ """Parameter context for python protocols.""" - +import tempfile from typing import List, Optional, Union, Dict from opentrons.protocols.api_support.types import APIVersion @@ -19,7 +19,7 @@ from opentrons.protocol_engine.types import ( RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) @@ -218,7 +218,7 @@ def set_parameters( parameter.value = validated_value def initialize_csv_files( - self, run_time_param_file_overrides: CSVRunTimeParamFilesType + self, run_time_param_file_overrides: CSVRuntimeParamPaths ) -> None: """Initializes the files for CSV parameters. @@ -226,7 +226,7 @@ def initialize_csv_files( This is intended for Opentrons internal use only and is not a guaranteed API. """ - for variable_name, file_id in run_time_param_file_overrides.items(): + for variable_name, file_path in run_time_param_file_overrides.items(): try: parameter = self._parameters[variable_name] except KeyError: @@ -240,11 +240,41 @@ def initialize_csv_files( f"File Id was provided for the parameter '{variable_name}'," f" but '{variable_name}' is not a CSV parameter." ) + # TODO(jbl 2024-08-02) This file opening should be moved elsewhere to provide more flexibility with files + # that may be opened as non-text or non-UTF-8 + # The parent folder in the path will be the file ID, so we can use that to resolve that here + file_id = file_path.parent.name + file_name = file_path.name + + # Read the contents of the actual file + with file_path.open() as csv_file: + contents = csv_file.read() + + # Open a temporary file with write permissions and write contents to that + temporary_file = tempfile.NamedTemporaryFile("r+") + temporary_file.write(contents) + temporary_file.flush() + + # Open a new file handler for the temporary file with read-only permissions and close the other + parameter_file = open(temporary_file.name, "r") + temporary_file.close() + + parameter.file_info = FileInfo(id=file_id, name=file_name) + parameter.value = parameter_file + + def close_csv_files(self) -> None: + """Close all file handlers for CSV parameters. - parameter.file_info = FileInfo(id=file_id, name="") - # TODO (spp, 2024-07-16): set the file name and assign the file as parameter.value. - # Most likely, we will be creating a temporary file copy of the original - # to pass onto the protocol context + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + for parameter in self._parameters.values(): + if ( + isinstance(parameter, csv_parameter_definition.CSVParameterDefinition) + and parameter.value is not None + ): + parameter.value.close() def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. diff --git a/api/src/opentrons/protocol_engine/__init__.py b/api/src/opentrons/protocol_engine/__init__.py index 22d04749c75e..2538b67daf52 100644 --- a/api/src/opentrons/protocol_engine/__init__.py +++ b/api/src/opentrons/protocol_engine/__init__.py @@ -18,7 +18,15 @@ CommandType, CommandIntent, ) -from .state import State, StateView, StateSummary, CommandSlice, CommandPointer, Config +from .state import ( + State, + StateView, + StateSummary, + CommandSlice, + CommandPointer, + Config, + CommandErrorSlice, +) from .plugins import AbstractPlugin from .types import ( @@ -81,6 +89,7 @@ "State", "StateView", "CommandSlice", + "CommandErrorSlice", "CommandPointer", # public value interfaces and models "LabwareOffset", diff --git a/api/src/opentrons/protocol_engine/state/__init__.py b/api/src/opentrons/protocol_engine/state/__init__.py index 80a88350263a..f9705905967f 100644 --- a/api/src/opentrons/protocol_engine/state/__init__.py +++ b/api/src/opentrons/protocol_engine/state/__init__.py @@ -7,6 +7,7 @@ CommandState, CommandView, CommandSlice, + CommandErrorSlice, CommandPointer, ) from .command_history import CommandEntry @@ -39,6 +40,7 @@ "CommandState", "CommandView", "CommandSlice", + "CommandErrorSlice", "CommandPointer", "CommandEntry", # labware state and values diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 1ad17867450c..c725c561ac36 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -117,6 +117,15 @@ class CommandSlice: total_length: int +@dataclass(frozen=True) +class CommandErrorSlice: + """A subset of all commands errors in state.""" + + commands_errors: List[ErrorOccurrence] + cursor: int + total_length: int + + @dataclass(frozen=True) class CommandPointer: """Brief info about a command and where to find it.""" @@ -619,6 +628,26 @@ def get_slice( total_length=total_length, ) + def get_errors_slice( + self, + cursor: int, + length: int, + ) -> CommandErrorSlice: + """Get a subset of commands error around a given cursor.""" + # start is inclusive, stop is exclusive + all_errors = self.get_all_errors() + total_length = len(all_errors) + actual_cursor = max(0, min(cursor, total_length - 1)) + stop = min(total_length, actual_cursor + length) + + sliced_errors = all_errors[actual_cursor:stop] + + return CommandErrorSlice( + commands_errors=sliced_errors, + cursor=actual_cursor, + total_length=total_length, + ) + def get_error(self) -> Optional[ErrorOccurrence]: """Get the run's fatal error, if there was one.""" run_error = self._state.run_error diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 60720c917ec6..58a798e90bdf 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -12,17 +12,15 @@ NozzleMap, ) from opentrons.protocol_engine.actions.actions import FailCommandAction -from opentrons.protocol_engine.commands.aspirate import Aspirate -from opentrons.protocol_engine.commands.dispense import Dispense -from opentrons.protocol_engine.commands.aspirate_in_place import AspirateInPlace -from opentrons.protocol_engine.commands.dispense_in_place import DispenseInPlace from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.protocol_engine.commands.pipetting_common import ( + LiquidNotFoundError, OverpressureError, OverpressureErrorInternalData, ) from opentrons.types import MountType, Mount as HwMount, Point +from .. import commands from .. import errors from ..types import ( LoadedPipette, @@ -34,31 +32,6 @@ CurrentPipetteLocation, TipGeometry, ) -from ..commands import ( - LoadPipetteResult, - AspirateResult, - AspirateInPlaceResult, - DispenseResult, - DispenseInPlaceResult, - MoveLabwareResult, - MoveToCoordinatesResult, - MoveToWellResult, - MoveRelativeResult, - MoveToAddressableAreaResult, - MoveToAddressableAreaForDropTipResult, - PickUpTipResult, - DropTipResult, - DropTipInPlaceResult, - HomeResult, - RetractAxisResult, - BlowOutResult, - BlowOutInPlaceResult, - unsafe, - TouchTipResult, - thermocycler, - heater_shaker, - PrepareToAspirateResult, -) from ..commands.configuring_common import ( PipetteConfigUpdateResultMixin, PipetteNozzleLayoutResultMixin, @@ -227,7 +200,7 @@ def _handle_command( # noqa: C901 private_result.pipette_id ] = private_result.nozzle_map - if isinstance(command.result, LoadPipetteResult): + if isinstance(command.result, commands.LoadPipetteResult): pipette_id = command.result.pipetteId self._state.pipettes_by_id[pipette_id] = LoadedPipette( @@ -247,7 +220,7 @@ def _handle_command( # noqa: C901 pipette_id ] = static_config.default_nozzle_map - elif isinstance(command.result, PickUpTipResult): + elif isinstance(command.result, commands.PickUpTipResult): pipette_id = command.params.pipetteId attached_tip = TipGeometry( length=command.result.tipLength, @@ -281,7 +254,11 @@ def _handle_command( # noqa: C901 elif isinstance( command.result, - (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ( + commands.DropTipResult, + commands.DropTipInPlaceResult, + commands.unsafe.UnsafeDropTipInPlaceResult, + ), ): pipette_id = command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None @@ -307,13 +284,15 @@ def _update_current_location( # noqa: C901 if isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - MoveToWellResult, - PickUpTipResult, - DropTipResult, - AspirateResult, - DispenseResult, - BlowOutResult, - TouchTipResult, + commands.MoveToWellResult, + commands.PickUpTipResult, + commands.DropTipResult, + commands.AspirateResult, + commands.DispenseResult, + commands.BlowOutResult, + commands.TouchTipResult, + commands.LiquidProbeResult, + commands.TryLiquidProbeResult, ), ): self._state.current_location = CurrentWell( @@ -321,11 +300,20 @@ def _update_current_location( # noqa: C901 labware_id=action.command.params.labwareId, well_name=action.command.params.wellName, ) - elif ( - isinstance(action, FailCommandAction) - and isinstance(action.running_command, (Aspirate, Dispense)) - and isinstance(action.error, DefinedErrorData) - and isinstance(action.error.public, OverpressureError) + elif isinstance(action, FailCommandAction) and ( + isinstance(action.error, DefinedErrorData) + and ( + ( + isinstance( + action.running_command, (commands.Aspirate, commands.Dispense) + ) + and isinstance(action.error.public, OverpressureError) + ) + or ( + isinstance(action.running_command, commands.LiquidProbe) + and isinstance(action.error.public, LiquidNotFoundError) + ) + ) ): self._state.current_location = CurrentWell( pipette_id=action.running_command.params.pipetteId, @@ -334,7 +322,10 @@ def _update_current_location( # noqa: C901 ) elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, - (MoveToAddressableAreaResult, MoveToAddressableAreaForDropTipResult), + ( + commands.MoveToAddressableAreaResult, + commands.MoveToAddressableAreaForDropTipResult, + ), ): self._state.current_location = CurrentAddressableArea( pipette_id=action.command.params.pipetteId, @@ -349,11 +340,11 @@ def _update_current_location( # noqa: C901 elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - HomeResult, - RetractAxisResult, - MoveToCoordinatesResult, - thermocycler.OpenLidResult, - thermocycler.CloseLidResult, + commands.HomeResult, + commands.RetractAxisResult, + commands.MoveToCoordinatesResult, + commands.thermocycler.OpenLidResult, + commands.thermocycler.CloseLidResult, ), ): self._state.current_location = None @@ -363,8 +354,8 @@ def _update_current_location( # noqa: C901 elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - heater_shaker.SetAndWaitForShakeSpeedResult, - heater_shaker.OpenLabwareLatchResult, + commands.heater_shaker.SetAndWaitForShakeSpeedResult, + commands.heater_shaker.OpenLabwareLatchResult, ), ): if action.command.result.pipetteRetracted: @@ -377,7 +368,7 @@ def _update_current_location( # noqa: C901 # This is necessary for safe motion planning in case the next movement # goes to the same labware (now in a new place). elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, MoveLabwareResult + action.command.result, commands.MoveLabwareResult ): moved_labware_id = action.command.params.labwareId if action.command.params.strategy == "usingGripper": @@ -398,17 +389,17 @@ def _update_deck_point( if isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - MoveToWellResult, - MoveToCoordinatesResult, - MoveRelativeResult, - MoveToAddressableAreaResult, - MoveToAddressableAreaForDropTipResult, - PickUpTipResult, - DropTipResult, - AspirateResult, - DispenseResult, - BlowOutResult, - TouchTipResult, + commands.MoveToWellResult, + commands.MoveToCoordinatesResult, + commands.MoveRelativeResult, + commands.MoveToAddressableAreaResult, + commands.MoveToAddressableAreaForDropTipResult, + commands.PickUpTipResult, + commands.DropTipResult, + commands.AspirateResult, + commands.DispenseResult, + commands.BlowOutResult, + commands.TouchTipResult, ), ): pipette_id = action.command.params.pipetteId @@ -421,7 +412,12 @@ def _update_deck_point( isinstance(action, FailCommandAction) and isinstance( action.running_command, - (Aspirate, Dispense, AspirateInPlace, DispenseInPlace), + ( + commands.Aspirate, + commands.Dispense, + commands.AspirateInPlace, + commands.DispenseInPlace, + ), ) and isinstance(action.error, DefinedErrorData) and isinstance(action.error.public, OverpressureError) @@ -437,10 +433,10 @@ def _update_deck_point( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - HomeResult, - RetractAxisResult, - thermocycler.OpenLidResult, - thermocycler.CloseLidResult, + commands.HomeResult, + commands.RetractAxisResult, + commands.thermocycler.OpenLidResult, + commands.thermocycler.CloseLidResult, ), ): self._clear_deck_point() @@ -448,15 +444,15 @@ def _update_deck_point( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - heater_shaker.SetAndWaitForShakeSpeedResult, - heater_shaker.OpenLabwareLatchResult, + commands.heater_shaker.SetAndWaitForShakeSpeedResult, + commands.heater_shaker.OpenLabwareLatchResult, ), ): if action.command.result.pipetteRetracted: self._clear_deck_point() elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, MoveLabwareResult + action.command.result, commands.MoveLabwareResult ): if action.command.params.strategy == "usingGripper": # All mounts will have been retracted. @@ -466,7 +462,8 @@ def _update_volumes( self, action: Union[SucceedCommandAction, FailCommandAction] ) -> None: if isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (AspirateResult, AspirateInPlaceResult) + action.command.result, + (commands.AspirateResult, commands.AspirateInPlaceResult), ): pipette_id = action.command.params.pipetteId previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 @@ -477,7 +474,8 @@ def _update_volumes( self._state.aspirated_volume_by_id[pipette_id] = next_volume elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (DispenseResult, DispenseInPlaceResult) + action.command.result, + (commands.DispenseResult, commands.DispenseInPlaceResult), ): pipette_id = action.command.params.pipetteId previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 @@ -488,13 +486,17 @@ def _update_volumes( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, - (BlowOutResult, BlowOutInPlaceResult, unsafe.UnsafeBlowOutInPlaceResult), + ( + commands.BlowOutResult, + commands.BlowOutInPlaceResult, + commands.unsafe.UnsafeBlowOutInPlaceResult, + ), ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, PrepareToAspirateResult + action.command.result, commands.PrepareToAspirateResult ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = 0 diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 9da731490436..6c19324870a2 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum from dataclasses import dataclass +from pathlib import Path from pydantic import ( BaseModel, Field, @@ -1069,3 +1070,4 @@ class CSVParameter(RTPBase): ] # update value types as more RTP types are added CSVRunTimeParamFilesType = Mapping[StrictStr, StrictStr] +CSVRuntimeParamPaths = Dict[str, Path] diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index bfe959ca0ebf..b0aabad34a46 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -44,7 +44,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocols.types import PythonProtocol @@ -186,7 +186,7 @@ async def load( protocol_source: ProtocolSource, python_parse_mode: PythonParseMode, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> None: """Load a Python or JSONv5(& older) ProtocolSource into managed ProtocolEngine.""" labware_definitions = await protocol_reader.extract_labware_definitions( @@ -209,7 +209,7 @@ async def load( protocol=protocol, parameter_context=self._parameter_context, run_time_param_overrides=run_time_param_values, - run_time_param_file_overrides=run_time_param_files, + run_time_param_file_overrides=run_time_param_paths, ) ) else: @@ -244,6 +244,7 @@ async def run_func() -> None: await self._protocol_executor.execute( protocol=protocol, context=context, + parameter_context=self._parameter_context, run_time_parameters_with_overrides=run_time_parameters_with_overrides, ) @@ -254,7 +255,7 @@ async def run( # noqa: D102 deck_configuration: DeckConfigurationType, protocol_source: Optional[ProtocolSource] = None, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, - run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, + run_time_param_paths: Optional[CSVRuntimeParamPaths] = None, python_parse_mode: PythonParseMode = PythonParseMode.NORMAL, ) -> RunResult: # TODO(mc, 2022-01-11): move load to runner creation, remove from `run` @@ -264,7 +265,7 @@ async def run( # noqa: D102 protocol_source=protocol_source, python_parse_mode=python_parse_mode, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) self.play(deck_configuration=deck_configuration) diff --git a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py index e1090d98fa4c..17f82b888469 100644 --- a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py +++ b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py @@ -15,7 +15,7 @@ from opentrons.protocol_engine import ProtocolEngine from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons.protocol_reader import ProtocolSource, ProtocolFileRole from opentrons.util.broker import Broker @@ -153,11 +153,16 @@ class PythonProtocolExecutor: async def execute( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext], run_time_parameters_with_overrides: Optional[Parameters], ) -> None: """Execute a PAPIv2 protocol with a given ProtocolContext in a child thread.""" await to_thread.run_sync( - run_protocol, protocol, context, run_time_parameters_with_overrides + run_protocol, + protocol, + context, + parameter_context, + run_time_parameters_with_overrides, ) @staticmethod @@ -165,7 +170,7 @@ def extract_run_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Extract the parameters defined in the protocol, overridden with values for the run.""" return exec_add_parameters( diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index fe5cf4483f64..0dc57e0ba1f0 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -21,7 +21,9 @@ StateSummary, CommandPointer, CommandSlice, + CommandErrorSlice, DeckType, + ErrorOccurrence, ) from ..protocol_engine.errors import RunStoppedError from ..protocol_engine.types import ( @@ -32,7 +34,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocol_engine.error_recovery_policy import ErrorRecoveryPolicy @@ -269,6 +271,23 @@ def get_command_slice( cursor=cursor, length=length ) + def get_command_error_slice( + self, + cursor: int, + length: int, + ) -> CommandErrorSlice: + """Get a slice of run commands errors. + + Args: + cursor: Requested index of first error in the returned slice. + If the cursor is omitted, a cursor will be selected automatically + based on the last error occurence. + length: Length of slice to return. + """ + return self._protocol_engine.state_view.commands.get_errors_slice( + cursor=cursor, length=length + ) + def get_command_recovery_target(self) -> Optional[CommandPointer]: """Get the current error recovery target.""" return self._protocol_engine.state_view.commands.get_recovery_target() @@ -281,6 +300,10 @@ def get_all_commands(self) -> List[Command]: """Get all run commands.""" return self._protocol_engine.state_view.commands.get_all() + def get_command_errors(self) -> List[ErrorOccurrence]: + """Get all run command errors.""" + return self._protocol_engine.state_view.commands.get_all_errors() + def get_run_status(self) -> EngineStatus: """Get the current execution status of the engine.""" return self._protocol_engine.state_view.commands.get_status() @@ -340,7 +363,7 @@ async def load( self, protocol_source: ProtocolSource, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], parse_mode: ParseMode, ) -> None: """Load a json/python protocol.""" @@ -356,7 +379,7 @@ async def load( # doesn't conform to the new rules. python_parse_mode=python_parse_mode, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) def get_is_okay_to_clear(self) -> bool: diff --git a/api/src/opentrons/protocols/execution/execute.py b/api/src/opentrons/protocols/execution/execute.py index 68bd22470c31..46f429839f53 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -1,7 +1,7 @@ import logging from typing import Optional -from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api import ProtocolContext, ParameterContext from opentrons.protocol_api._parameters import Parameters from opentrons.protocols.execution.execute_python import exec_run from opentrons.protocols.execution.json_dispatchers import ( @@ -21,25 +21,36 @@ def run_protocol( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext] = None, run_time_parameters_with_overrides: Optional[Parameters] = None, ) -> None: """Run a protocol. :param protocol: The :py:class:`.protocols.types.Protocol` to execute :param context: The protocol context to use. + :param parameter_context: The parameter context to use if running with runtime parameters. :param run_time_parameters_with_overrides: Run time parameters defined in the protocol, updated with the run's RTP override values. When we are running either simulate or execute, this will be None (until RTP is supported in cli commands) """ if isinstance(protocol, PythonProtocol): - if protocol.api_level >= APIVersion(2, 0): - exec_run( - proto=protocol, - context=context, - run_time_parameters_with_overrides=run_time_parameters_with_overrides, - ) - else: - raise RuntimeError(f"Unsupported python API version: {protocol.api_level}") + try: + if protocol.api_level >= APIVersion(2, 0): + exec_run( + proto=protocol, + context=context, + run_time_parameters_with_overrides=run_time_parameters_with_overrides, + ) + else: + raise RuntimeError( + f"Unsupported python API version: {protocol.api_level}" + ) + except Exception: + raise + finally: + # TODO(jbl 2024-08-02) this should be more tightly bound to the opening of the csv files + if parameter_context is not None: + parameter_context.close_csv_files() else: if protocol.contents["schemaVersion"] == 3: ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents) diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 59c9db943dfe..5d7793144d3b 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -12,7 +12,7 @@ from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) @@ -71,7 +71,7 @@ def _raise_pretty_protocol_error(exception: Exception, filename: str) -> None: def _parse_and_set_parameters( parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], new_globs: Dict[Any, Any], filename: str, ) -> Parameters: @@ -111,7 +111,7 @@ def exec_add_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Exec the add_parameters function and get the final run time parameters with overrides.""" new_globs: Dict[Any, Any] = {} diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py index 46b47a04282c..a4b4e30e5c63 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -4,6 +4,7 @@ from .exceptions import RuntimeParameterRequired, ParameterValueError +# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder class CSVParameter: def __init__(self, csv_file: Optional[TextIO]) -> None: self._file = csv_file 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 2b86fe9259f5..5aa7d04a2ee4 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 @@ -25,6 +25,7 @@ CommandState, CommandView, CommandSlice, + CommandErrorSlice, CommandPointer, RunResult, QueueStatus, @@ -903,7 +904,7 @@ def test_get_current() -> None: def test_get_slice_empty() -> None: """It should return a slice from the tail if no current command.""" subject = get_command_view(commands=[]) - result = subject.get_slice(cursor=None, length=2) + result = subject.get_slice(cursor=0, length=2) assert result == CommandSlice(commands=[], cursor=0, total_length=0) @@ -1005,24 +1006,37 @@ def test_get_slice_default_cursor_running() -> None: ) -def test_get_slice_default_cursor_queued() -> None: - """It should select a cursor automatically.""" - command_1 = create_succeeded_command(command_id="command-id-1") - command_2 = create_succeeded_command(command_id="command-id-2") - command_3 = create_succeeded_command(command_id="command-id-3") - command_4 = create_queued_command(command_id="command-id-4") - command_5 = create_queued_command(command_id="command-id-5") +def test_get_errors_slice_empty() -> None: + """It should return a slice from the tail if no current command.""" + subject = get_command_view(failed_command_errors=[]) + result = subject.get_errors_slice(cursor=0, length=2) + + assert result == CommandErrorSlice(commands_errors=[], cursor=0, total_length=0) + + +def test_get_errors_slice() -> None: + """It should return a slice of all command errors.""" + error_1 = ErrorOccurrence.construct(id="error-id-1") # type: ignore[call-arg] + error_2 = ErrorOccurrence.construct(id="error-id-2") # type: ignore[call-arg] + error_3 = ErrorOccurrence.construct(id="error-id-3") # type: ignore[call-arg] + error_4 = ErrorOccurrence.construct(id="error-id-4") # type: ignore[call-arg] subject = get_command_view( - commands=[command_1, command_2, command_3, command_4, command_5], - running_command_id=None, - queued_command_ids=[command_4.id, command_5.id], + failed_command_errors=[error_1, error_2, error_3, error_4] ) - result = subject.get_slice(cursor=None, length=2) + result = subject.get_errors_slice(cursor=1, length=3) - assert result == CommandSlice( - commands=[command_3, command_4], - cursor=2, - total_length=5, + assert result == CommandErrorSlice( + commands_errors=[error_2, error_3, error_4], + cursor=1, + total_length=4, + ) + + result = subject.get_errors_slice(cursor=-3, length=10) + + assert result == CommandErrorSlice( + commands_errors=[error_1, error_2, error_3, error_4], + cursor=0, + total_length=4, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index c8d60395b3b8..a49c92556054 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -10,6 +10,8 @@ from opentrons.protocol_engine import commands as cmd from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.protocol_engine.commands.pipetting_common import ( + LiquidNotFoundError, + LiquidNotFoundErrorInternalData, OverpressureError, OverpressureErrorInternalData, ) @@ -502,6 +504,95 @@ def test_blow_out_clears_volume( well_name="dispense-well-name", ), ), + # liquidProbe and tryLiquidProbe succeeding and with overpressure error + ( + SucceedCommandAction( + command=cmd.LiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + completedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + params=cmd.LiquidProbeParams( + labwareId="liquid-probe-labware-id", + wellName="liquid-probe-well-name", + pipetteId="pipette-id", + ), + result=cmd.LiquidProbeResult( + position=DeckPoint(x=0, y=0, z=0), z_position=0 + ), + ), + private_result=None, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="liquid-probe-labware-id", + well_name="liquid-probe-well-name", + ), + ), + ( + FailCommandAction( + running_command=cmd.LiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.RUNNING, + params=cmd.LiquidProbeParams( + labwareId="liquid-probe-labware-id", + wellName="liquid-probe-well-name", + pipetteId="pipette-id", + ), + ), + error=DefinedErrorData( + public=LiquidNotFoundError( + id="error-id", + createdAt=datetime.now(), + ), + private=LiquidNotFoundErrorInternalData( + position=DeckPoint(x=0, y=0, z=0) + ), + ), + command_id="command-id", + error_id="error-id", + failed_at=datetime.now(), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="liquid-probe-labware-id", + well_name="liquid-probe-well-name", + ), + ), + ( + SucceedCommandAction( + command=cmd.TryLiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + completedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + params=cmd.TryLiquidProbeParams( + labwareId="try-liquid-probe-labware-id", + wellName="try-liquid-probe-well-name", + pipetteId="pipette-id", + ), + result=cmd.TryLiquidProbeResult( + position=DeckPoint(x=0, y=0, z=0), + z_position=0, + ), + ), + private_result=None, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="try-liquid-probe-labware-id", + well_name="try-liquid-probe-well-name", + ), + ), ), ) def test_movement_commands_update_current_well( diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index e975e90fa734..14307411d535 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -645,7 +645,7 @@ async def test_load_legacy_python( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -668,6 +668,7 @@ async def test_load_legacy_python( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=python_runner_subject._parameter_context, run_time_parameters_with_overrides=None, ), ) @@ -727,7 +728,7 @@ async def test_load_python_with_pe_papi_core( protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) decoy.verify(protocol_engine.add_plugin(matchers.IsA(LegacyContextPlugin)), times=0) @@ -790,7 +791,7 @@ async def test_load_legacy_json( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -811,6 +812,7 @@ async def test_load_legacy_json( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=None, run_time_parameters_with_overrides=None, ), ) diff --git a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py index 2c5e243c3ecf..6e1c04949f80 100644 --- a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py +++ b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py @@ -337,7 +337,7 @@ async def test_load_json( await json_protocol_subject.load( protocol_source=protocol_source, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, parse_mode=ParseMode.NORMAL, ) @@ -364,7 +364,7 @@ async def test_load_python( protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) decoy.verify( @@ -372,7 +372,7 @@ async def test_load_python( protocol_source=protocol_source, python_parse_mode=PythonParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) ) @@ -396,7 +396,7 @@ async def test_load_json_raises_no_protocol( await live_protocol_subject.load( protocol_source=protocol_source, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, parse_mode=ParseMode.NORMAL, ) diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index 4bedd4bc8e6d..fe673268a504 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -1,7 +1,7 @@ { "before_you_begin_do_you_want_to_blowout": "Before you begin, do you need to preserve aspirated liquid?", "begin_removal": "Begin removal", - "blowout_complete": "blowout complete", + "blowout_complete": "Blowout complete", "blowout_liquid": "Blow out liquid", "cant_safely_drop_tips": "Can't safely drop tips", "choose_blowout_location": "choose blowout location", @@ -9,7 +9,7 @@ "confirm_blowout_location": "Is the pipette positioned where the liquids should be blown out?", "confirm_drop_tip_location": "Is the pipette positioned where the tips should be dropped?", "confirm_removal_and_home": "Confirm removal and home", - "drop_tip_complete": "tip drop complete", + "drop_tip_complete": "Tip drop complete", "drop_tip_failed": "The drop tip could not be completed. Contact customer support for assistance.", "drop_tips": "drop tips", "error_dropping_tips": "Error dropping tips", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 8b495eae864a..5217a95dc4c4 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -85,6 +85,7 @@ "heater_shaker_extra_attention": "Use latch controls for easy placement of labware.", "heater_shaker_labware_list_view": "To add labware, use the toggle to control the latch", "how_offset_data_works": "How labware offsets work", + "individiual_well_volume": "Individual well volume", "initial_liquids_num_plural": "{{count}} initial liquids", "initial_liquids_num": "{{count}} initial liquid", "initial_location": "Initial Location", @@ -115,6 +116,8 @@ "learn_more_about_offset_data": "Learn more about Labware Offset Data", "learn_more_about_robot_cal_link": "Learn more about robot calibration", "learn_more": "Learn more", + "liquid_information": "Liquid information", + "liquid_name": "Liquid name", "liquid_setup_step_description": "View liquid starting locations and volumes", "liquid_setup_step_title": "Liquids", "liquids_not_in_setup": "No liquids used in this protocol", @@ -265,6 +268,7 @@ "tip_length_cal_description": "This measures the Z distance between the bottom of the tip and the pipette’s nozzle. If you redo the tip length calibration for the tip you used to calibrate a pipette, you will also have to redo that Pipette Offset Calibration.", "tip_length_cal_title": "Tip Length Calibration", "tip_length_calibration": "tip length calibration", + "total_liquid_volume": "Total volume", "update_deck_config": "Update deck configuration", "update_deck": "Update deck", "updated": "Updated", @@ -276,6 +280,7 @@ "value_out_of_range": "Value must be between {{min}}-{{max}}", "value": "Value", "values_are_view_only": "Values are view-only", + "variable_well_amount": "Variable well amount", "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/buttons/RadioButton.tsx b/app/src/atoms/buttons/RadioButton.tsx index 7876866d56d7..bfed6273f1ef 100644 --- a/app/src/atoms/buttons/RadioButton.tsx +++ b/app/src/atoms/buttons/RadioButton.tsx @@ -93,7 +93,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { /> {buttonLabel} diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx index 61e6b6de67a6..5e79b1ff8bd5 100644 --- a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx +++ b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx @@ -141,8 +141,8 @@ export function SimpleWizardBodyContent(props: Props): JSX.Element { <> {isSuccess ? ( Success Icon diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 9f4bef400eee..6a704c966990 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -205,7 +205,7 @@ export function ProtocolRunHeader({ determineTipStatus, resetTipStatus, setTipStatusResolved, - pipettesWithTip, + aPipetteWithTip, } = useTipAttachmentStatus({ runId, runRecord, @@ -421,7 +421,7 @@ export function ProtocolRunHeader({ ) : null} @@ -496,11 +496,11 @@ export function ProtocolRunHeader({ robotName={robotName} /> ) : null} - {showDTWiz && mostRecentRunId === runId ? ( + {showDTWiz && aPipetteWithTip != null ? ( setTipStatusResolved().then(toggleDTWiz)} /> ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index ab436e5973f8..dcec582de1dc 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' + import { RUN_ACTION_TYPE_PLAY, RUN_STATUS_STOPPED, @@ -21,8 +22,9 @@ import { Flex, Icon, InfoScreen, - SPACING, LegacyStyledText, + OVERFLOW_AUTO, + SPACING, TYPOGRAPHY, useHoverTooltip, } from '@opentrons/components' @@ -132,7 +134,12 @@ export function ProtocolRunRuntimeParameters({ ) : ( <> - + {t('name')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx index 58bf6cd951a7..9f9dcf62df33 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx @@ -10,17 +10,17 @@ import { ALIGN_CENTER, BORDERS, Box, + DeckInfoLabel, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, Flex, JUSTIFY_CENTER, JUSTIFY_FLEX_START, + JUSTIFY_SPACE_BETWEEN, LiquidIcon, SIZE_AUTO, SPACING, - LegacyStyledText, - TYPOGRAPHY, StyledText, } from '@opentrons/components' import { getModuleDisplayName, MICRO_LITERS } from '@opentrons/shared-data' @@ -29,18 +29,17 @@ import { ANALYTICS_EXPAND_LIQUID_SETUP_ROW, ANALYTICS_OPEN_LIQUID_LABWARE_DETAIL_MODAL, } from '../../../../redux/analytics' +import { useIsFlex } from '../../hooks' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getLocationInfoNames } from '../utils/getLocationInfoNames' import { LiquidsLabwareDetailsModal } from './LiquidsLabwareDetailsModal' -import { - getTotalVolumePerLiquidId, - getTotalVolumePerLiquidLabwarePair, -} from './utils' +import { getTotalVolumePerLiquidId, getVolumePerWell } from './utils' import type { LabwareByLiquidId } from '@opentrons/api-client' interface SetupLiquidsListProps { runId: string + robotName: string } const HIDE_SCROLLBAR = css` @@ -60,8 +59,10 @@ export const CARD_OUTLINE_BORDER_STYLE = css` ` export function SetupLiquidsList(props: SetupLiquidsListProps): JSX.Element { - const { runId } = props + const { runId, robotName } = props const protocolData = useMostRecentCompletedAnalysis(runId) + const { t } = useTranslation('protocol_setup') + const isFlex = useIsFlex(robotName) const liquidsInLoadOrder = parseLiquidsInLoadOrder( protocolData?.liquids ?? [], @@ -77,6 +78,29 @@ export function SetupLiquidsList(props: SetupLiquidsListProps): JSX.Element { data-testid="SetupLiquidsList_ListView" gridGap={SPACING.spacing8} > + + + {t('liquid_information')} + + + {t('total_liquid_volume')} + + {liquidsInLoadOrder?.map(liquid => ( ))} @@ -97,10 +122,18 @@ interface LiquidsListItemProps { displayColor: string displayName: string runId: string + isFlex: boolean } export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { - const { liquidId, description, displayColor, displayName, runId } = props + const { + liquidId, + description, + displayColor, + displayName, + runId, + isFlex, + } = props const { t } = useTranslation('protocol_setup') const [openItem, setOpenItem] = React.useState(false) const [liquidDetailsLabwareId, setLiquidDetailsLabwareId] = React.useState< @@ -164,30 +197,30 @@ export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { marginTop={SPACING.spacing16} marginBottom={SPACING.spacing8} > - {t('location')} - - + {t('labware_name')} - - + - {t('volume')} - + {t('individiual_well_volume')} + {labwareByLiquidId[liquidId].map((labware, index) => { const { @@ -219,27 +252,22 @@ export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { justifyContent={JUSTIFY_FLEX_START} gridGap={SPACING.spacing16} > - - - {slotName} - + + {isFlex ? ( + + ) : ( + + {slotName} + + )} - + {labwareName} - + {adapterName != null ? ( - {moduleModel != null @@ -250,23 +278,27 @@ export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { : t('on_adapter', { adapterName: adapterName, })} - + ) : null} - - {getTotalVolumePerLiquidLabwarePair( + {getVolumePerWell( liquidId, labware.labwareId, labwareByLiquidId - ).toFixed(1)}{' '} - {MICRO_LITERS} - + ) == null + ? t('variable_well_amount') + : `${getVolumePerWell( + liquidId, + labware.labwareId, + labwareByLiquidId + )} ${MICRO_LITERS}`} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx index 4d50b071908d..476896dfa3d1 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx @@ -18,12 +18,10 @@ import { ANALYTICS_EXPAND_LIQUID_SETUP_ROW, ANALYTICS_OPEN_LIQUID_LABWARE_DETAIL_MODAL, } from '../../../../../redux/analytics' +import { useIsFlex } from '../../../hooks' import { getLocationInfoNames } from '../../utils/getLocationInfoNames' import { SetupLiquidsList } from '../SetupLiquidsList' -import { - getTotalVolumePerLiquidId, - getTotalVolumePerLiquidLabwarePair, -} from '../utils' +import { getTotalVolumePerLiquidId, getVolumePerWell } from '../utils' import { LiquidsLabwareDetailsModal } from '../LiquidsLabwareDetailsModal' import { useNotifyRunQuery } from '../../../../../resources/runs' @@ -58,6 +56,7 @@ const MOCK_LABWARE_INFO_BY_LIQUID_ID = { vi.mock('../utils') vi.mock('../../utils/getLocationInfoNames') +vi.mock('../../../hooks') vi.mock('../LiquidsLabwareDetailsModal') vi.mock('@opentrons/api-client') vi.mock('../../../../../redux/analytics') @@ -73,9 +72,10 @@ let mockTrackEvent: Mock describe('SetupLiquidsList', () => { let props: React.ComponentProps beforeEach(() => { - props = { runId: '123' } + props = { runId: '123', robotName: 'test_flex' } vi.mocked(getTotalVolumePerLiquidId).mockReturnValue(400) - vi.mocked(getTotalVolumePerLiquidLabwarePair).mockReturnValue(200) + vi.mocked(useIsFlex).mockReturnValue(false) + vi.mocked(getVolumePerWell).mockReturnValue(200) vi.mocked(getLocationInfoNames).mockReturnValue({ labwareName: 'mock labware name', slotName: '4', @@ -98,6 +98,11 @@ describe('SetupLiquidsList', () => { vi.mocked(useNotifyRunQuery).mockReturnValue({} as any) }) + it('renders the table headers', () => { + render(props) + screen.getByText('Liquid information') + screen.getByText('Total volume') + }) it('renders the total volume of the liquid, sample display name, and description', () => { render(props) screen.getAllByText(nestedTextMatcher('400.0 µL')) @@ -117,8 +122,8 @@ describe('SetupLiquidsList', () => { }) screen.getByText('Location') screen.getByText('Labware name') - screen.getByText('Volume') - screen.getAllByText(nestedTextMatcher('200.0 µL')) + screen.getByText('Individual well volume') + screen.getByText('200 µL') screen.getByText('4') screen.getByText('mock labware name') }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx index ed563673775a..daa2a7e114fa 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx @@ -46,7 +46,7 @@ export function SetupLiquids({ > {toggleGroup} {selectedValue === t('list_view') ? ( - + ) : ( )} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts index f7bf94adebc2..a4b5f28a6f4a 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts @@ -78,6 +78,22 @@ export function getTotalVolumePerLiquidId( return parseFloat(totalVolume.toFixed(1)) } +export function getVolumePerWell( + liquidId: string, + labwareId: string, + labwareByLiquidId: LabwareByLiquidId +): number | null { + const labwareInfo = labwareByLiquidId[liquidId] + const volumes = labwareInfo + .filter(labware => labware.labwareId === labwareId) + .flatMap(labware => Object.values(labware.volumeByWell)) + if (new Set(volumes).size === 1) { + return parseFloat(volumes[0].toFixed(1)) + } else { + return null + } +} + export function getTotalVolumePerLiquidLabwarePair( liquidId: string, labwareId: string, diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 157538c9ff84..70b16c61b55c 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -14,7 +14,6 @@ import { RUN_STATUS_SUCCEEDED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, } from '@opentrons/api-client' import { useHost, @@ -88,6 +87,7 @@ import { useNotifyRunQuery, useCurrentRunId } from '../../../../resources/runs' import { useDropTipWizardFlows, useTipAttachmentStatus, + DropTipWizardFlows, } from '../../../DropTipWizardFlows' import { useErrorRecoveryFlows, @@ -340,10 +340,7 @@ describe('ProtocolRunHeader', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) vi.mocked(useHost).mockReturnValue({} as any) vi.mocked(useTipAttachmentStatus).mockReturnValue({ - pipettesWithTip: [ - instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, - ], + aPipetteWithTip: instrumentsResponseLeftPipetteFixture, areTipsAttached: true, determineTipStatus: mockDetermineTipStatus, resetTipStatus: vi.fn(), @@ -384,6 +381,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ProtocolDropTipModal).mockReturnValue(
MOCK_DROP_TIP_MODAL
) + vi.mocked(DropTipWizardFlows).mockReturnValue( +
MOCK_DROP_TIP_WIZARD_FLOWS
+ ) }) afterEach(() => { @@ -1076,4 +1076,14 @@ describe('ProtocolRunHeader', () => { render() screen.getByText('MOCK_ERROR_RECOVERY') }) + + it('renders DropTipWizardFlows when conditions are met', () => { + vi.mocked(useDropTipWizardFlows).mockReturnValue({ + showDTWiz: true, + toggleDTWiz: vi.fn(), + }) + + render() + screen.getByText('MOCK_DROP_TIP_WIZARD_FLOWS') + }) }) diff --git a/app/src/organisms/DropTipWizardFlows/Success.tsx b/app/src/organisms/DropTipWizardFlows/Success.tsx index 7fb10ae9cc48..5e72dd66281a 100644 --- a/app/src/organisms/DropTipWizardFlows/Success.tsx +++ b/app/src/organisms/DropTipWizardFlows/Success.tsx @@ -1,19 +1,23 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { - COLORS, + StyledText, PrimaryButton, TEXT_TRANSFORM_CAPITALIZE, JUSTIFY_FLEX_END, + ALIGN_CENTER, Flex, SPACING, + DIRECTION_COLUMN, + RESPONSIVENESS, + JUSTIFY_CENTER, } from '@opentrons/components' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' import { SmallButton } from '../../atoms/buttons' +import SuccessIcon from '../../assets/images/icon_success.png' import type { DropTipWizardContainerProps } from './types' +import { css } from 'styled-components' type SuccessProps = DropTipWizardContainerProps & { message: string @@ -29,32 +33,58 @@ export const Success = (props: SuccessProps): JSX.Element => { issuedCommandsType, } = props - const { i18n } = useTranslation(['drop_tip_wizard', 'shared']) - return ( - <> - {issuedCommandsType === 'fixit' ? : null} - + + Success Icon + + {message} + + + {isOnDevice ? ( - - - + ) : ( {proceedText} )} - - + + ) } + +const WIZARD_CONTAINER_STYLE = css` + min-height: 394px; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 472px; + } +` diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 0cb1872b196b..71de14567faa 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import capitalize from 'lodash/capitalize' -import head from 'lodash/head' import NiceModal, { useModal } from '@ebay/nice-modal-react' import { Trans, useTranslation } from 'react-i18next' @@ -23,7 +22,7 @@ import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' import type { PipetteWithTip } from '.' interface TipsAttachedModalProps { - pipettesWithTip: PipetteWithTip[] + aPipetteWithTip: PipetteWithTip host: HostConfig | null setTipStatusResolved: (onEmpty?: () => void) => Promise } @@ -38,11 +37,11 @@ export const handleTipsAttachedModal = ( const TipsAttachedModal = NiceModal.create( (props: TipsAttachedModalProps): JSX.Element => { - const { pipettesWithTip, host, setTipStatusResolved } = props + const { aPipetteWithTip, host, setTipStatusResolved } = props const { t } = useTranslation(['drop_tip_wizard']) const modal = useModal() - const { mount, specs } = head(pipettesWithTip) as PipetteWithTip + const { mount, specs } = aPipetteWithTip const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() const tipsAttachedHeader: ModalHeaderBaseProps = { @@ -57,7 +56,9 @@ const TipsAttachedModal = NiceModal.create( } const is96Channel = specs.channels === 96 - const displayMountText = is96Channel ? '96-Channel' : capitalize(mount) + const displayMountText = is96Channel + ? '96-Channel' + : capitalize(mount as string) return ( diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx index d0763e3e3072..bd1cc918ea55 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx @@ -37,6 +37,21 @@ const MOCK_ACTUAL_PIPETTE = { }, } as PipetteModelSpecs +const mockPipetteWithTip: PipetteWithTip = { + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, +} + +const mockSecondPipetteWithTip: PipetteWithTip = { + mount: 'right', + specs: MOCK_ACTUAL_PIPETTE, +} + +const mockPipettesWithTip: PipetteWithTip[] = [ + mockPipetteWithTip, + mockSecondPipetteWithTip, +] + describe('useTipAttachmentStatus', () => { let mockGetPipettesWithTipAttached: Mock @@ -44,6 +59,7 @@ describe('useTipAttachmentStatus', () => { mockGetPipettesWithTipAttached = vi.mocked(getPipettesWithTipAttached) vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) vi.mocked(DropTipWizard).mockReturnValue(
MOCK DROP TIP WIZ
) + mockGetPipettesWithTipAttached.mockResolvedValue(mockPipettesWithTip) }) afterEach(() => { @@ -54,16 +70,10 @@ describe('useTipAttachmentStatus', () => { const { result } = renderHook(() => useTipAttachmentStatus({} as any)) expect(result.current.areTipsAttached).toBe(false) - expect(result.current.pipettesWithTip).toEqual([]) + expect(result.current.aPipetteWithTip).toEqual(null) }) it('should determine tip status and update state accordingly', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - { mount: 'right', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -71,15 +81,10 @@ describe('useTipAttachmentStatus', () => { }) expect(result.current.areTipsAttached).toBe(true) - expect(result.current.pipettesWithTip).toEqual(mockPipettesWithTip) + expect(result.current.aPipetteWithTip).toEqual(mockPipetteWithTip) }) it('should reset tip status', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -88,16 +93,10 @@ describe('useTipAttachmentStatus', () => { }) expect(result.current.areTipsAttached).toBe(false) - expect(result.current.pipettesWithTip).toEqual([]) + expect(result.current.aPipetteWithTip).toEqual(null) }) it('should set tip status resolved and update state', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - { mount: 'right', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -105,14 +104,11 @@ describe('useTipAttachmentStatus', () => { result.current.setTipStatusResolved() }) - expect(result.current.pipettesWithTip).toEqual([mockPipettesWithTip[1]]) + expect(result.current.aPipetteWithTip).toEqual(mockSecondPipetteWithTip) }) it('should call onEmptyCache callback when cache becomes empty', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) + mockGetPipettesWithTipAttached.mockResolvedValueOnce([mockPipetteWithTip]) const onEmptyCacheMock = vi.fn() const { result } = renderHook(() => useTipAttachmentStatus({} as any)) diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 135ff4e0e6e2..edd24d50e108 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -33,24 +33,24 @@ const ninetySixSpecs = { channels: 96, } as PipetteModelSpecs -const MOCK_PIPETTES_WITH_TIP: PipetteWithTip[] = [ - { mount: LEFT, specs: MOCK_ACTUAL_PIPETTE }, -] -const MOCK_96_WITH_TIP: PipetteWithTip[] = [ - { mount: LEFT, specs: ninetySixSpecs }, -] +const MOCK_A_PIPETTE_WITH_TIP: PipetteWithTip = { + mount: LEFT, + specs: MOCK_ACTUAL_PIPETTE, +} + +const MOCK_96_WITH_TIP: PipetteWithTip = { mount: LEFT, specs: ninetySixSpecs } const mockSetTipStatusResolved = vi.fn() const MOCK_HOST: HostConfig = { hostname: 'MOCK_HOST' } -const render = (pipettesWithTips: PipetteWithTip[]) => { +const render = (aPipetteWithTip: PipetteWithTip) => { return renderWithProviders(