diff --git a/api-client/src/dataFiles/getCsvFile.ts b/api-client/src/dataFiles/getCsvFile.ts new file mode 100644 index 00000000000..93d28bca7ee --- /dev/null +++ b/api-client/src/dataFiles/getCsvFile.ts @@ -0,0 +1,12 @@ +import { GET, request } from '../request' + +import type { CsvFileDataResponse } from './types' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function getCsvFile( + config: HostConfig, + fileId: string +): ResponsePromise { + return request(GET, `/dataFiles/${fileId}`, null, config) +} diff --git a/api-client/src/dataFiles/index.ts b/api-client/src/dataFiles/index.ts index 3496c8acaa0..01733e38291 100644 --- a/api-client/src/dataFiles/index.ts +++ b/api-client/src/dataFiles/index.ts @@ -1,3 +1,4 @@ +export { getCsvFile } from './getCsvFile' export { getCsvFileRaw } from './getCsvFileRaw' export { uploadCsvFile } from './uploadCsvFile' diff --git a/api-client/src/dataFiles/types.ts b/api-client/src/dataFiles/types.ts index 41029bc4380..011fe9dabb3 100644 --- a/api-client/src/dataFiles/types.ts +++ b/api-client/src/dataFiles/types.ts @@ -13,10 +13,12 @@ export interface CsvFileData { name: string } -export interface UploadedCsvFileResponse { +export interface CsvFileDataResponse { data: CsvFileData } +export type UploadedCsvFileResponse = CsvFileDataResponse + export interface UploadedCsvFilesResponse { data: CsvFileData[] } diff --git a/api-client/src/runs/commands/getRunCommandErrors.ts b/api-client/src/runs/commands/getRunCommandErrors.ts new file mode 100644 index 00000000000..0f961e1a892 --- /dev/null +++ b/api-client/src/runs/commands/getRunCommandErrors.ts @@ -0,0 +1,19 @@ +import { GET, request } from '../../request' + +import type { ResponsePromise } from '../../request' +import type { HostConfig } from '../../types' +import type { GetCommandsParams, RunCommandErrors } from '../types' + +export function getRunCommandErrors( + config: HostConfig, + runId: string, + params: GetCommandsParams +): ResponsePromise { + return request( + GET, + `/runs/${runId}/commandErrors`, + null, + config, + params + ) +} diff --git a/api-client/src/runs/commands/types.ts b/api-client/src/runs/commands/types.ts index 1bcdadcc15f..cd18924201c 100644 --- a/api-client/src/runs/commands/types.ts +++ b/api-client/src/runs/commands/types.ts @@ -1,10 +1,15 @@ -import type { RunTimeCommand } from '@opentrons/shared-data' +import type { RunTimeCommand, RunCommandError } from '@opentrons/shared-data' export interface GetCommandsParams { cursor: number | null // the index of the command at the center of the window pageLength: number // the number of items to include } +export interface RunCommandErrors { + data: RunCommandError[] + meta: GetCommandsParams & { totalLength: number } +} + // NOTE: this incantation allows us to omit a key from each item in a union distributively // this means we can, for example, maintain the associated commandType and params after the Omit is applied type DistributiveOmit = T extends any ? Omit : never diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 02bf0c0e036..9f314f4b025 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -9,6 +9,7 @@ export { getCommand } from './commands/getCommand' export { getCommands } from './commands/getCommands' export { getCommandsAsPreSerializedList } from './commands/getCommandsAsPreSerializedList' export { createRunAction } from './createRunAction' +export { getRunCommandErrors } from './commands/getRunCommandErrors' export * from './createLabwareOffset' export * from './createLabwareDefinition' export * from './constants' diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index 081edca651a..9c4ccc62a0d 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -132,6 +132,11 @@ This table lists the correspondence between Protocol API versions and robot soft Changes in API Versions ======================= +Version 2.20 +------------ + +- You can now call :py:obj:`.ProtocolContext.define_liquid()` without supplying a ``description`` or ``display_color``. + Version 2.19 ------------ diff --git a/api/release-notes.md b/api/release-notes.md index d073629a97c..b855d1d10a1 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -4,6 +4,26 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr [technical change log]: https://github.com/Opentrons/opentrons/releases [opentrons issue tracker]: https://github.com/Opentrons/opentrons/issues?q=is%3Aopen+is%3Aissue+label%3Abug +By installing and using Opentrons software, you agree to the Opentrons End-User License Agreement (EULA). You can view the EULA at [opentrons.com/eula](https://opentrons.com/eula). + +--- + +## Opentrons Robot Software Changes in 8.0.0 + +Welcome to the v8.0.0 release of the Opentrons robot software! + +### New Features + +- Create, store, and run quick transfers on Flex. +- Define and use CSV runtime parameters in Python protocols. +- Detect the presence or absence of liquid in a well (Flex pipettes only), and continue or pause the protocol based on the result. +- Automatically pause Flex protocol runs when detecting overpressure, allowing for error recovery and run resumption. + +### Improved Features + +- Provides more partial tip pickup configurations. All multi-channel pipettes now support single and partial column pickup, and the Flex 96-channel pipette now supports row pickup. +- Improves homing behavior when a Flex protocol completes or is canceled with liquid-filled tips attached to the pipette. + --- ## Opentrons Robot Software Changes in 7.5.0 diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index 7270e517644..8e90e08190b 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/execute.py b/api/src/opentrons/execute.py index e4109d5d390..2e6a5870f7d 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -142,23 +142,23 @@ def get_protocol_api( When this function is called, modules and instruments will be recached. :param version: The API version to use. This must be lower than - ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. - It may be specified either as a string (``'2.0'``) or - as a ``protocols.types.APIVersion`` - (``APIVersion(2, 0)``). + ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. + It may be specified either as a string (``'2.0'``) or + as a ``protocols.types.APIVersion`` + (``APIVersion(2, 0)``). :param bundled_labware: If specified, a mapping from labware names to - labware definitions for labware to consider in the - protocol. Note that if you specify this, _only_ - labware in this argument will be allowed in the - protocol. This is preparation for a beta feature - and is best not used. + labware definitions for labware to consider in the + protocol. Note that if you specify this, *only* + labware in this argument will be allowed in the + protocol. This is preparation for a beta feature + and is best not used. :param bundled_data: If specified, a mapping from filenames to contents - for data to be available in the protocol from - :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. + for data to be available in the protocol from + :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. :param extra_labware: A mapping from labware load names to custom labware definitions. - If this is ``None`` (the default), and this function is called on a robot, - it will look for labware in the ``labware`` subdirectory of the Jupyter - data directory. + If this is ``None`` (the default), and this function is called on a robot, + it will look for labware in the ``labware`` subdirectory of the Jupyter + data directory. :return: The protocol context. """ if isinstance(version, str): @@ -313,18 +313,18 @@ def execute( :param protocol_file: The protocol file to execute :param protocol_name: The name of the protocol file. This is required - internally, but it may not be a thing we can get - from the protocol_file argument. + internally, but it may not be a thing we can get + from the ``protocol_file`` argument. :param propagate_logs: Whether this function should allow logs from the - Opentrons stack to propagate up to the root handler. - This can be useful if you're integrating this - function in a larger application, but most logs that - occur during protocol simulation are best associated - with the actions in the protocol that cause them. - Default: ``False`` + Opentrons stack to propagate up to the root handler. + This can be useful if you're integrating this + function in a larger application, but most logs that + occur during protocol simulation are best associated + with the actions in the protocol that cause them. + Default: ``False`` :param log_level: The level of logs to emit on the command line: - ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. - Defaults to ``"warning"``. + ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. + Defaults to ``"warning"``. :param emit_runlog: A callback for printing the run log. If specified, this will be called whenever a command adds an entry to the run log, which can be used for display and progress @@ -353,17 +353,17 @@ def execute( ``KeyError``. :param custom_labware_paths: A list of directories to search for custom labware. - Loads valid labware from these paths and makes them available - to the protocol context. If this is ``None`` (the default), and - this function is called on a robot, it will look in the ``labware`` - subdirectory of the Jupyter data directory. + Loads valid labware from these paths and makes them available + to the protocol context. If this is ``None`` (the default), and + this function is called on a robot, it will look in the ``labware`` + subdirectory of the Jupyter data directory. :param custom_data_paths: A list of directories or files to load custom - data files from. Ignored if the apiv2 feature - flag if not set. Entries may be either files or - directories. Specified files and the - non-recursive contents of specified directories - are presented by the protocol context in - ``ProtocolContext.bundled_data``. + data files from. Ignored if the apiv2 feature + flag if not set. Entries may be either files or + directories. Specified files and the + non-recursive contents of specified directories + are presented by the protocol context in + ``ProtocolContext.bundled_data``. """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs @@ -457,10 +457,10 @@ def main() -> int: """Handler for command line invocation to run a protocol. :param argv: The arguments the program was invoked with; this is usually - :py:obj:`sys.argv` but if you want to override that you can. + :py:obj:`sys.argv` but if you want to override that you can. :returns int: A success or failure value suitable for use as a shell - return code passed to :py:obj:`sys.exit` (0 means success, - anything else is a kind of failure). + return code passed to :py:obj:`sys.exit` (0 means success, + anything else is a kind of failure). """ parser = argparse.ArgumentParser( prog="opentrons_execute", description="Run an OT-2 protocol" diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 975f2996c98..8cc4bd1154e 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.csv_parameter_interface 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 32528dab8d6..8ca2bdb2c2a 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -1,8 +1,8 @@ """Parameter context for python protocols.""" - from typing import List, Optional, Union, Dict from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.util import requires_version from opentrons.protocols.parameters import ( parameter_definition, csv_parameter_definition, @@ -19,7 +19,7 @@ from opentrons.protocol_engine.types import ( RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) @@ -169,6 +169,7 @@ def add_str( ) self._parameters[parameter.variable_name] = parameter + @requires_version(2, 20) def add_csv_file( self, display_name: str, @@ -218,7 +219,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 +227,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: @@ -241,10 +242,12 @@ def initialize_csv_files( f" but '{variable_name}' is not a CSV parameter." ) - 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 + # 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 + + parameter.file_info = FileInfo(id=file_id, name=file_name) + parameter.value = file_path def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. @@ -269,7 +272,7 @@ def export_parameters_for_protocol(self) -> Parameters: for parameter in self._parameters.values(): value: UserFacingTypes if isinstance(parameter, csv_parameter_definition.CSVParameterDefinition): - value = parameter.as_csv_parameter_interface() + value = parameter.as_csv_parameter_interface(self._api_version) else: value = parameter.value parameters_for_protocol[parameter.variable_name] = value diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 59b7d1d8aee..054af703fe7 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -87,6 +87,16 @@ ] +class _Unset: + """A sentinel value for when no value has been supplied for an argument, + when `None` is already taken for some other meaning. + + User code should never use this explicitly. + """ + + pass + + class ProtocolContext(CommandPublisher): """A context for the state of a protocol. @@ -1197,17 +1207,54 @@ def set_rail_lights(self, on: bool) -> None: @requires_version(2, 14) def define_liquid( - self, name: str, description: Optional[str], display_color: Optional[str] + self, + name: str, + description: Union[str, None, _Unset] = _Unset(), + display_color: Union[str, None, _Unset] = _Unset(), ) -> Liquid: + # This first line of the docstring overrides the method signature in our public + # docs, which would otherwise have the `_Unset()`s expanded to a bunch of junk. """ + define_liquid(self, name: str, description: Optional[str] = None, display_color: Optional[str] = None) + Define a liquid within a protocol. :param str name: A human-readable name for the liquid. - :param str description: An optional description of the liquid. - :param str display_color: An optional hex color code, with hash included, to represent the specified liquid. Standard three-value, four-value, six-value, and eight-value syntax are all acceptable. + :param Optional[str] description: An optional description of the liquid. + :param Optional[str] display_color: An optional hex color code, with hash included, + to represent the specified liquid. For example, ``"#48B1FA"``. + Standard three-value, four-value, six-value, and eight-value syntax are all + acceptable. :return: A :py:class:`~opentrons.protocol_api.Liquid` object representing the specified liquid. + + .. versionchanged:: 2.20 + You can now omit the ``description`` and ``display_color`` arguments. + Formerly, when you didn't want to provide values, you had to supply + ``description=None`` and ``display_color=None`` explicitly. """ + desc_and_display_color_omittable_since = APIVersion(2, 20) + if isinstance(description, _Unset): + if self._api_version < desc_and_display_color_omittable_since: + raise APIVersionError( + api_element="Calling `define_liquid()` without a `description`", + current_version=str(self._api_version), + until_version=str(desc_and_display_color_omittable_since), + message="Use a newer API version or explicitly supply `description=None`.", + ) + else: + description = None + if isinstance(display_color, _Unset): + if self._api_version < desc_and_display_color_omittable_since: + raise APIVersionError( + api_element="Calling `define_liquid()` without a `display_color`", + current_version=str(self._api_version), + until_version=str(desc_and_display_color_omittable_since), + message="Use a newer API version or explicitly supply `display_color=None`.", + ) + else: + display_color = None + return self._core.define_liquid( name=name, description=description, diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 207c417cf5e..1ad6628ae24 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -208,7 +208,7 @@ def ensure_and_convert_deck_slot( api_element=f"Specifying a deck slot like '{deck_slot}'", until_version=f"{_COORDINATE_DECK_LABEL_VERSION_GATE}", current_version=f"{api_version}", - message=f" Increase your protocol's apiLevel, or use slot '{alternative}' instead.", + message=f"Increase your protocol's apiLevel, or use slot '{alternative}' instead.", ) return parsed_slot.to_equivalent_for_robot_type(robot_type) diff --git a/api/src/opentrons/protocol_engine/__init__.py b/api/src/opentrons/protocol_engine/__init__.py index 22d04749c75..2538b67daf5 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 80a88350263..f9705905967 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 1ad17867450..c725c561ac3 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 60720c917ec..58a798e90bd 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 9da73149043..6c19324870a 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/create_simulating_orchestrator.py b/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py index 0aa5114b5a5..dd826eeade3 100644 --- a/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py +++ b/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py @@ -47,7 +47,11 @@ async def create_simulating_orchestrator( robot_type=robot_type ) - # TODO(mc, 2021-08-25): move initial home to protocol engine + # TODO(mm, 2024-08-06): This home has theoretically been replaced by Protocol Engine + # `home` commands within the `RunOrchestrator` or `ProtocolRunner`. However, it turns + # out that this `HardwareControlAPI`-level home is accidentally load-bearing, + # working around Protocol Engine bugs where *both* layers need to be homed for + # certain commands to work. https://opentrons.atlassian.net/browse/EXEC-646 await simulating_hardware_api.home() protocol_engine = await create_protocol_engine( diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index bfe959ca0eb..22c809bcde5 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: @@ -254,7 +254,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 +264,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 e1090d98fa4..f20012f1dfe 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 @@ -157,7 +157,10 @@ async def execute( ) -> 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, + run_time_parameters_with_overrides, ) @staticmethod @@ -165,7 +168,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 fe5cf4483f6..0dc57e0ba1f 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 68bd22470c3..ff52b3b1dc5 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -15,6 +15,9 @@ from opentrons.protocols.types import PythonProtocol, Protocol from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter +from opentrons.protocols.parameters.exceptions import RuntimeParameterRequired + MODULE_LOG = logging.getLogger(__name__) @@ -32,14 +35,29 @@ def run_protocol( 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: + if protocol.api_level >= APIVersion(2, 18): + for parameter in context.params.get_all().values(): + if isinstance(parameter, CSVParameter): + try: + parameter.file.close() + # This will be raised if the csv file wasn't set, which means it was never opened, + # so we can safely skip this. + except RuntimeParameterRequired: + pass 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 afc1ec434c6..b9a4a1b8806 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -13,7 +13,7 @@ from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) @@ -76,7 +76,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: @@ -116,7 +116,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/csv_parameter_definition.py b/api/src/opentrons/protocols/parameters/csv_parameter_definition.py index 342f4e1f180..d23b7d70f0b 100644 --- a/api/src/opentrons/protocols/parameters/csv_parameter_definition.py +++ b/api/src/opentrons/protocols/parameters/csv_parameter_definition.py @@ -1,18 +1,20 @@ """CSV Parameter definition and associated classes/functions.""" -from typing import Optional, TextIO +from pathlib import Path +from typing import Optional from opentrons.protocol_engine.types import ( RunTimeParameter, CSVParameter as ProtocolEngineCSVParameter, FileInfo, ) +from opentrons.protocols.api_support.types import APIVersion from . import validation from .parameter_definition import AbstractParameterDefinition -from .types import CSVParameter +from .csv_parameter_interface import CSVParameter -class CSVParameterDefinition(AbstractParameterDefinition[Optional[TextIO]]): +class CSVParameterDefinition(AbstractParameterDefinition[Optional[Path]]): """The definition for a user defined CSV file parameter.""" def __init__( @@ -28,7 +30,7 @@ def __init__( self._display_name = validation.ensure_display_name(display_name) self._variable_name = validation.ensure_variable_name(variable_name) self._description = validation.ensure_description(description) - self._value: Optional[TextIO] = None + self._value: Optional[Path] = None self._file_info: Optional[FileInfo] = None @property @@ -37,13 +39,13 @@ def variable_name(self) -> str: return self._variable_name @property - def value(self) -> Optional[TextIO]: + def value(self) -> Optional[Path]: """The current set file for the CSV parameter. Defaults to None on definition creation.""" return self._value @value.setter - def value(self, new_file: TextIO) -> None: - self._value = new_file + def value(self, new_path: Path) -> None: + self._value = new_path @property def file_info(self) -> Optional[FileInfo]: @@ -53,8 +55,8 @@ def file_info(self) -> Optional[FileInfo]: def file_info(self, file_info: FileInfo) -> None: self._file_info = file_info - def as_csv_parameter_interface(self) -> CSVParameter: - return CSVParameter(csv_file=self._value) + def as_csv_parameter_interface(self, api_version: APIVersion) -> CSVParameter: + return CSVParameter(csv_path=self._value, api_version=api_version) def as_protocol_engine_type(self) -> RunTimeParameter: """Returns CSV parameter as a Protocol Engine type to send to client.""" diff --git a/api/src/opentrons/protocols/parameters/csv_parameter_interface.py b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py new file mode 100644 index 00000000000..40a099558d4 --- /dev/null +++ b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py @@ -0,0 +1,64 @@ +import csv +from pathlib import Path +from typing import Optional, TextIO, Any, List + +from opentrons.protocols.api_support.types import APIVersion + +from . import parameter_file_reader +from .exceptions import 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_path: Optional[Path], api_version: APIVersion) -> None: + self._path = csv_path + self._file: Optional[TextIO] = None + self._contents: Optional[str] = None + self._api_version = api_version + + @property + def file(self) -> TextIO: + """Returns the file handler for the CSV file.""" + if self._file is None: + self._file = parameter_file_reader.open_file_path(self._path) + return self._file + + @property + def contents(self) -> str: + """Returns the full contents of the CSV file as a single string.""" + if self._contents is None: + self.file.seek(0) + self._contents = self.file.read() + return self._contents + + def parse_as_csv( + self, detect_dialect: bool = True, **kwargs: Any + ) -> List[List[str]]: + """Returns a list of rows with each row represented as a list of column elements. + + If there is a header for the CSV that will be the first row in the list (i.e. `.rows()[0]`). + All elements will be represented as strings, even if they are numeric in nature. + """ + rows: List[List[str]] = [] + if detect_dialect: + try: + self.file.seek(0) + dialect = csv.Sniffer().sniff(self.file.read(1024)) + self.file.seek(0) + reader = csv.reader(self.file, dialect, **kwargs) + except (UnicodeDecodeError, csv.Error): + raise ParameterValueError( + "Cannot parse dialect or contents from provided CSV file." + ) + else: + try: + reader = csv.reader(self.file, **kwargs) + except (UnicodeDecodeError, csv.Error): + raise ParameterValueError("Cannot parse provided CSV file.") + try: + for row in reader: + rows.append(row) + except (UnicodeDecodeError, csv.Error): + raise ParameterValueError("Cannot parse provided CSV file.") + self.file.seek(0) + return rows diff --git a/api/src/opentrons/protocols/parameters/parameter_file_reader.py b/api/src/opentrons/protocols/parameters/parameter_file_reader.py new file mode 100644 index 00000000000..9a39c2fa0dc --- /dev/null +++ b/api/src/opentrons/protocols/parameters/parameter_file_reader.py @@ -0,0 +1,26 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Optional, TextIO + +from .exceptions import RuntimeParameterRequired + + +def open_file_path(file_path: Optional[Path]) -> TextIO: + """Ensure file path is set and open up the file in a safe read-only temporary file.""" + if file_path is None: + raise RuntimeParameterRequired( + "CSV parameter needs to be set to a file for full analysis or run." + ) + # Read the contents of the actual file + with file_path.open() as fh: + contents = fh.read() + + # Open a temporary file with write permissions and write contents to that + temporary_file = 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 + read_only_temp_file = open(temporary_file.name, "r") + temporary_file.close() + return read_only_temp_file diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py index 46b47a04282..631d686b7e7 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -1,66 +1,11 @@ -import csv -from typing import TypeVar, Union, TypedDict, TextIO, Optional, List, Any +from pathlib import Path +from typing import TypeVar, Union, TypedDict -from .exceptions import RuntimeParameterRequired, ParameterValueError - - -class CSVParameter: - def __init__(self, csv_file: Optional[TextIO]) -> None: - self._file = csv_file - self._contents: Optional[str] = None - - @property - def file(self) -> TextIO: - """Returns the file handler for the CSV file.""" - if self._file is None: - raise RuntimeParameterRequired( - "CSV parameter needs to be set to a file for full analysis or run." - ) - return self._file - - @property - def contents(self) -> str: - """Returns the full contents of the CSV file as a single string.""" - if self._contents is None: - self.file.seek(0) - self._contents = self.file.read() - return self._contents - - def parse_as_csv( - self, detect_dialect: bool = True, **kwargs: Any - ) -> List[List[str]]: - """Returns a list of rows with each row represented as a list of column elements. - - If there is a header for the CSV that will be the first row in the list (i.e. `.rows()[0]`). - All elements will be represented as strings, even if they are numeric in nature. - """ - rows: List[List[str]] = [] - if detect_dialect: - try: - self.file.seek(0) - dialect = csv.Sniffer().sniff(self.file.read(1024)) - self.file.seek(0) - reader = csv.reader(self.file, dialect, **kwargs) - except (UnicodeDecodeError, csv.Error): - raise ParameterValueError( - "Cannot parse dialect or contents from provided CSV file." - ) - else: - try: - reader = csv.reader(self.file, **kwargs) - except (UnicodeDecodeError, csv.Error): - raise ParameterValueError("Cannot parse provided CSV file.") - try: - for row in reader: - rows.append(row) - except (UnicodeDecodeError, csv.Error): - raise ParameterValueError("Cannot parse provided CSV file.") - self.file.seek(0) - return rows +from .csv_parameter_interface import CSVParameter PrimitiveAllowedTypes = Union[str, int, float, bool] -AllAllowedTypes = Union[str, int, float, bool, TextIO, None] +AllAllowedTypes = Union[str, int, float, bool, Path, None] UserFacingTypes = Union[str, int, float, bool, CSVParameter] ParamType = TypeVar("ParamType", bound=AllAllowedTypes) diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 83dac473012..b061c280811 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -228,10 +228,9 @@ def get_protocol_api( use_virtual_hardware: bool = True, ) -> protocol_api.ProtocolContext: """ - Build and return a ``protocol_api.ProtocolContext`` - connected to Virtual Smoothie. + Build and return a ``protocol_api.ProtocolContext`` that simulates robot control. - This can be used to run protocols from interactive Python sessions + This can be used to simulate protocols from interactive Python sessions such as Jupyter or an interpreter on the command line: .. code-block:: python @@ -242,28 +241,31 @@ def get_protocol_api( >>> instr.home() :param version: The API version to use. This must be lower than - ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. - It may be specified either as a string (``'2.0'``) or - as a ``protocols.types.APIVersion`` - (``APIVersion(2, 0)``). + ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. + It may be specified either as a string (``'2.0'``) or + as a ``protocols.types.APIVersion`` + (``APIVersion(2, 0)``). :param bundled_labware: If specified, a mapping from labware names to - labware definitions for labware to consider in the - protocol. Note that if you specify this, _only_ - labware in this argument will be allowed in the - protocol. This is preparation for a beta feature - and is best not used. + labware definitions for labware to consider in the + protocol. Note that if you specify this, *only* + labware in this argument will be allowed in the + protocol. This is preparation for a beta feature + and is best not used. :param bundled_data: If specified, a mapping from filenames to contents - for data to be available in the protocol from - :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. + for data to be available in the protocol from + :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. :param extra_labware: A mapping from labware load names to custom labware definitions. - If this is ``None`` (the default), and this function is called on a robot, - it will look for labware in the ``labware`` subdirectory of the Jupyter - data directory. - :param hardware_simulator: If specified, a hardware simulator instance. + If this is ``None`` (the default), and this function is called on a robot, + it will look for labware in the ``labware`` subdirectory of the Jupyter + data directory. + :param hardware_simulator: This is only for internal use by Opentrons. If specified, + it's a hardware simulator instance to reuse instead of creating a fresh one. :param robot_type: The type of robot to simulate: either ``"Flex"`` or ``"OT-2"``. - If you're running this function on a robot, the default is the type of that - robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. - :param use_virtual_hardware: If true, use the protocol engines virtual hardware, if false use the lower level hardware simulator. + If you're running this function on a robot, the default is the type of that + robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. + :param use_virtual_hardware: This is only for internal use by Opentrons. + If ``True``, use the Protocol Engine's virtual hardware. If ``False``, use the + lower level hardware simulator. :return: The protocol context. """ if isinstance(version, str): @@ -321,12 +323,18 @@ def get_protocol_api( hardware_api=checked_hardware, bundled_data=bundled_data, extra_labware=extra_labware, - use_virtual_hardware=use_virtual_hardware, + use_pe_virtual_hardware=use_virtual_hardware, ) # Intentional difference from execute.get_protocol_api(): # For the caller's convenience, we home the virtual hardware so they don't get MustHomeErrors. # Since this hardware is virtual, there's no harm in commanding this "movement" implicitly. + # + # Calling `checked_hardware_sync.home()` is a hack. It ought to be redundant with + # `context.home()`. We need it here to work around a Protocol Engine simulation bug + # where both the `HardwareControlAPI` level and the `ProtocolEngine` level need to + # be homed for certain commands to work. https://opentrons.atlassian.net/browse/EXEC-646 + checked_hardware.sync.home() context.home() return context @@ -435,15 +443,15 @@ def simulate( """ Simulate the protocol itself. - This is a one-stop function to simulate a protocol, whether python or json, - no matter the api version, from external (i.e. not bound up in other + This is a one-stop function to simulate a protocol, whether Python or JSON, + no matter the API version, from external (i.e. not bound up in other internal server infrastructure) sources. - To simulate an opentrons protocol from other places, pass in a file like - object as protocol_file; this function either returns (if the simulation + To simulate an opentrons protocol from other places, pass in a file-like + object as ``protocol_file``; this function either returns (if the simulation has no problems) or raises an exception. - To call from the command line use either the autogenerated entrypoint + To call from the command line, use either the autogenerated entrypoint ``opentrons_simulate`` (``opentrons_simulate.exe``, on windows) or ``python -m opentrons.simulate``. @@ -474,36 +482,37 @@ def simulate( :param protocol_file: The protocol file to simulate. :param file_name: The name of the file :param custom_labware_paths: A list of directories to search for custom labware. - Loads valid labware from these paths and makes them available - to the protocol context. If this is ``None`` (the default), and - this function is called on a robot, it will look in the ``labware`` - subdirectory of the Jupyter data directory. + Loads valid labware from these paths and makes them available + to the protocol context. If this is ``None`` (the default), and + this function is called on a robot, it will look in the ``labware`` + subdirectory of the Jupyter data directory. :param custom_data_paths: A list of directories or files to load custom - data files from. Ignored if the apiv2 feature - flag if not set. Entries may be either files or - directories. Specified files and the - non-recursive contents of specified directories - are presented by the protocol context in - ``protocol_api.ProtocolContext.bundled_data``. - :param hardware_simulator_file_path: A path to a JSON file defining a - hardware simulator. + data files from. Ignored if the apiv2 feature + flag if not set. Entries may be either files or + directories. Specified files and the + non-recursive contents of specified directories + are presented by the protocol context in + ``protocol_api.ProtocolContext.bundled_data``. + :param hardware_simulator_file_path: A path to a JSON file defining the simulated + hardware. This is mainly for internal use by Opentrons, and is not necessary + to simulate protocols. :param duration_estimator: For internal use only. - Optional duration estimator object. + Optional duration estimator object. :param propagate_logs: Whether this function should allow logs from the - Opentrons stack to propagate up to the root handler. - This can be useful if you're integrating this - function in a larger application, but most logs that - occur during protocol simulation are best associated - with the actions in the protocol that cause them. - Default: ``False`` + Opentrons stack to propagate up to the root handler. + This can be useful if you're integrating this + function in a larger application, but most logs that + occur during protocol simulation are best associated + with the actions in the protocol that cause them. + Default: ``False`` :param log_level: The level of logs to capture in the run log: - ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. - Defaults to ``"warning"``. + ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. + Defaults to ``"warning"``. :returns: A tuple of a run log for user output, and possibly the required - data to write to a bundle to bundle this protocol. The bundle is - only emitted if bundling is allowed - and this is an unbundled Protocol API - v2 python protocol. In other cases it is None. + data to write to a bundle to bundle this protocol. The bundle is + only emitted if bundling is allowed + and this is an unbundled Protocol API + v2 python protocol. In other cases it is None. """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs @@ -636,8 +645,7 @@ def get_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: Useful if you want to use this module as a component of another CLI program and want to add its arguments. - :param parser: A parser to add arguments to. If not specified, one will be - created. + :param parser: A parser to add arguments to. If not specified, one will be created. :returns argparse.ArgumentParser: The parser with arguments added. """ parser.add_argument( @@ -794,7 +802,7 @@ def _create_live_context_pe( deck_type: str, extra_labware: Dict[str, "LabwareDefinitionDict"], bundled_data: Optional[Dict[str, bytes]], - use_virtual_hardware: bool = True, + use_pe_virtual_hardware: bool = True, ) -> ProtocolContext: """Return a live ProtocolContext that controls the robot through ProtocolEngine.""" assert api_version >= ENGINE_CORE_API_VERSION @@ -804,7 +812,7 @@ def _create_live_context_pe( create_protocol_engine_in_thread( hardware_api=hardware_api_wrapped, config=_get_protocol_engine_config( - robot_type, virtual=use_virtual_hardware + robot_type, use_pe_virtual_hardware=use_pe_virtual_hardware ), error_recovery_policy=error_recovery_policy.never_recover, drop_tips_after_run=False, @@ -910,7 +918,9 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: hardware_api_wrapped = hardware_api.wrapped() protocol_engine = await create_protocol_engine( hardware_api=hardware_api_wrapped, - config=_get_protocol_engine_config(robot_type, virtual=True), + config=_get_protocol_engine_config( + robot_type, use_pe_virtual_hardware=True + ), error_recovery_policy=error_recovery_policy.never_recover, load_fixed_trash=should_load_fixed_trash(protocol_source.config), ) @@ -936,6 +946,13 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: ), ) + # TODO(mm, 2024-08-06): This home is theoretically redundant with Protocol + # Engine `home` commands within the `RunOrchestrator`. However, we need this to + # work around Protocol Engine bugs where both the `HardwareControlAPI` level + # and the `ProtocolEngine` level need to be homed for certain commands to work. + # https://opentrons.atlassian.net/browse/EXEC-646 + await hardware_api_wrapped.home() + scraper = _CommandScraper(stack_logger, log_level, protocol_runner.broker) with scraper.scrape(): result = await orchestrator.run( @@ -961,15 +978,17 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: return asyncio.run(run(protocol_source)) -def _get_protocol_engine_config(robot_type: RobotType, virtual: bool) -> Config: +def _get_protocol_engine_config( + robot_type: RobotType, use_pe_virtual_hardware: bool +) -> Config: """Return a Protocol Engine config to execute protocols on this device.""" return Config( robot_type=robot_type, deck_type=DeckType(deck_type_for_simulation(robot_type)), ignore_pause=True, - use_virtual_pipettes=virtual, - use_virtual_modules=virtual, - use_virtual_gripper=virtual, + use_virtual_pipettes=use_pe_virtual_hardware, + use_virtual_modules=use_pe_virtual_hardware, + use_virtual_gripper=use_pe_virtual_hardware, use_simulated_deck_config=True, ) @@ -992,7 +1011,6 @@ def main() -> int: parser = get_arguments(parser) args = parser.parse_args() - # Try to migrate api v1 containers if needed # TODO(mm, 2022-12-01): Configure the DurationEstimator with the correct deck type. duration_estimator = DurationEstimator() if args.estimate_duration else None diff --git a/api/tests/opentrons/cli/test_cli.py b/api/tests/opentrons/cli/test_cli.py index 4eb77a844fc..79d46dc1000 100644 --- a/api/tests/opentrons/cli/test_cli.py +++ b/api/tests/opentrons/cli/test_cli.py @@ -324,7 +324,7 @@ def test_file_required_error( python_protocol_source = textwrap.dedent( # Raises an exception during runner load. """\ - requirements = {"robotType": "OT-2", "apiLevel": "2.18"} + requirements = {"robotType": "OT-2", "apiLevel": "2.20"} def add_parameters(parameters): parameters.add_csv_file( @@ -350,7 +350,7 @@ def run(protocol): assert result.json_output["liquids"] == [] assert result.json_output["modules"] == [] assert result.json_output["config"] == { - "apiVersion": [2, 18], + "apiVersion": [2, 20], "protocolType": "python", } assert result.json_output["files"] == [{"name": "protocol.py", "role": "main"}] diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index a4fcbcff69b..20a8f090374 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -14,11 +14,11 @@ TypedDict, ) from typing_extensions import Literal -from math import copysign +from math import copysign, isclose import pytest import types from decoy import Decoy -from mock import AsyncMock, patch, Mock, PropertyMock, MagicMock +from mock import AsyncMock, patch, Mock, PropertyMock, MagicMock, call from hypothesis import given, strategies, settings, HealthCheck, assume, example from opentrons.calibration_storage.types import CalibrationStatus, SourceType @@ -856,6 +856,183 @@ async def test_liquid_probe( ) # should raise no exceptions +@pytest.mark.parametrize( + "mount, head_node, pipette_node", + [ + (OT3Mount.LEFT, NodeId.head_l, NodeId.pipette_left), + (OT3Mount.RIGHT, NodeId.head_r, NodeId.pipette_right), + ], +) +async def test_liquid_probe_plunger_moves( + mock_move_to: AsyncMock, + ot3_hardware: ThreadManager[OT3API], + hardware_backend: OT3Simulator, + head_node: NodeId, + pipette_node: Axis, + mount: OT3Mount, + fake_liquid_settings: LiquidProbeSettings, + mock_current_position_ot3: AsyncMock, + mock_move_to_plunger_bottom: AsyncMock, + mock_gantry_position: AsyncMock, +) -> None: + """Verify the plunger moves in liquid_probe.""" + # This test verifies that both: + # - the plunger movements in each liquid probe pass are what we expect + # - liquid probe successfully chooses the correct distance to move + # when approaching its max z distance + instr_data = AttachedPipette( + config=load_pipette_data.load_definition( + PipetteModelType("p1000"), PipetteChannelType(1), PipetteVersionType(3, 4) + ), + id="fakepip", + ) + await ot3_hardware.cache_pipette(mount, instr_data, None) + pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] + + assert pipette + await ot3_hardware.add_tip(mount, 100) + await ot3_hardware.home() + mock_move_to.return_value = None + + with patch.object( + hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) + ) as mock_liquid_probe: + + mock_liquid_probe.side_effect = [ + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + None, + ] + + fake_max_z_dist = 75.0 + config = ot3_hardware.config.liquid_sense + mount_speed = config.mount_speed + non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( + mount_speed + ) + + probe_pass_overlap = 0.1 + probe_pass_z_offset_mm = non_responsive_z_mm + probe_pass_overlap + probe_safe_reset_mm = max(2.0, probe_pass_z_offset_mm) + + # simulate multiple passes of liquid probe + mock_gantry_position.side_effect = [ + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=82.15), + Point(x=0, y=0, z=64.3), + Point(x=0, y=0, z=46.45), + Point(x=0, y=0, z=28.6), + Point(x=0, y=0, z=25), + ] + probe_start_pos = await ot3_hardware.gantry_position(mount) + safe_plunger_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_safe_reset_mm, + ) + + p_impulse_mm = config.plunger_impulse_time * config.plunger_speed + p_total_mm = pipette.plunger_positions.bottom - pipette.plunger_positions.top + p_working_mm = p_total_mm - (pipette.backlash_distance + p_impulse_mm) + + max_z_time = ( + fake_max_z_dist - (probe_start_pos.z - safe_plunger_pos.z) + ) / config.mount_speed + p_travel_required_for_z = max_z_time * config.plunger_speed + await ot3_hardware.liquid_probe(mount, fake_max_z_dist) + + max_z_distance = fake_max_z_dist + # simulate multiple passes of liquid_probe plunger moves + for _pass in mock_liquid_probe.call_args_list: + plunger_move = _pass[0][1] + expected_plunger_move = ( + min(p_travel_required_for_z, p_working_mm) + p_impulse_mm + ) + assert isclose(plunger_move, expected_plunger_move) + + mount_travel_time = plunger_move / config.plunger_speed + mount_travel_distance = mount_speed * mount_travel_time + max_z_distance -= mount_travel_distance + + move_mount_z_time = (max_z_distance + probe_pass_z_offset_mm) / mount_speed + p_travel_required_for_z = move_mount_z_time * config.plunger_speed + + +@pytest.mark.parametrize( + "mount, head_node, pipette_node", + [ + (OT3Mount.LEFT, NodeId.head_l, NodeId.pipette_left), + (OT3Mount.RIGHT, NodeId.head_r, NodeId.pipette_right), + ], +) +async def test_liquid_probe_mount_moves( + mock_move_to: AsyncMock, + ot3_hardware: ThreadManager[OT3API], + hardware_backend: OT3Simulator, + head_node: NodeId, + pipette_node: Axis, + mount: OT3Mount, + fake_liquid_settings: LiquidProbeSettings, + mock_current_position_ot3: AsyncMock, + mock_move_to_plunger_bottom: AsyncMock, + mock_gantry_position: AsyncMock, +) -> None: + """Verify move targets for one singular liquid pass probe.""" + instr_data = AttachedPipette( + config=load_pipette_data.load_definition( + PipetteModelType("p1000"), PipetteChannelType(1), PipetteVersionType(3, 4) + ), + id="fakepip", + ) + await ot3_hardware.cache_pipette(mount, instr_data, None) + pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] + + assert pipette + await ot3_hardware.add_tip(mount, 100) + await ot3_hardware.home() + mock_move_to.return_value = None + + with patch.object( + hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) + ): + + fake_max_z_dist = 10.0 + config = ot3_hardware.config.liquid_sense + mount_speed = config.mount_speed + non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( + mount_speed + ) + + probe_pass_overlap = 0.1 + probe_pass_z_offset_mm = non_responsive_z_mm + probe_pass_overlap + probe_safe_reset_mm = max(2.0, probe_pass_z_offset_mm) + + mock_gantry_position.return_value = Point(x=0, y=0, z=100) + probe_start_pos = await ot3_hardware.gantry_position(mount) + safe_plunger_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_safe_reset_mm, + ) + pass_start_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_pass_z_offset_mm, + ) + await ot3_hardware.liquid_probe(mount, fake_max_z_dist) + expected_moves = [ + call(mount, safe_plunger_pos), + call(mount, pass_start_pos), + call(mount, Point(z=probe_start_pos.z + 2)), + call(mount, probe_start_pos), + ] + assert mock_move_to.call_args_list == expected_moves + + async def test_multi_liquid_probe( mock_move_to: AsyncMock, ot3_hardware: ThreadManager[OT3API], diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 6674e228b2d..1e1dda706c6 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -1153,7 +1153,7 @@ def test_home( decoy.verify(mock_core.home(), times=1) -def test_add_liquid( +def test_define_liquid( decoy: Decoy, mock_core: ProtocolCore, subject: ProtocolContext ) -> None: """It should add a liquid to the state.""" @@ -1177,6 +1177,43 @@ def test_add_liquid( assert result == expected_result +@pytest.mark.parametrize( + ("api_version", "expect_success"), + [ + (APIVersion(2, 19), False), + (APIVersion(2, 20), True), + ], +) +def test_define_liquid_arg_defaulting( + expect_success: bool, + decoy: Decoy, + mock_core: ProtocolCore, + subject: ProtocolContext, +) -> None: + """Test API version dependent behavior for missing description and display_color.""" + success_result = Liquid( + _id="water-id", name="water", description=None, display_color=None + ) + decoy.when( + mock_core.define_liquid(name="water", description=None, display_color=None) + ).then_return(success_result) + + if expect_success: + assert ( + subject.define_liquid( + name="water" + # description and display_color omitted. + ) + == success_result + ) + else: + with pytest.raises(APIVersionError): + subject.define_liquid( + name="water" + # description and display_color omitted. + ) + + def test_bundled_data( decoy: Decoy, mock_core_map: LoadedCoreMap, mock_deck: Deck, mock_core: ProtocolCore ) -> None: 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 2b86fe9259f..5aa7d04a2ee 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 c8d60395b3b..a49c9255605 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 e975e90fa73..cd945c33e64 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() @@ -727,7 +727,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 +790,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() diff --git a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py index 2c5e243c3ec..6e1c04949f8 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/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py b/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py index 0bb257cabfd..04dee0512b5 100644 --- a/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py +++ b/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py @@ -1,12 +1,13 @@ """Tests for the CSV Parameter Definitions.""" import inspect -import tempfile -from io import TextIOWrapper +from pathlib import Path import pytest from decoy import Decoy from opentrons.protocol_engine.types import CSVParameter, FileInfo +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION from opentrons.protocols.parameters import validation as mock_validation from opentrons.protocols.parameters.csv_parameter_definition import ( create_csv_parameter, @@ -21,6 +22,12 @@ def _patch_parameter_validation(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(mock_validation, name, decoy.mock(func=func)) +@pytest.fixture +def api_version() -> APIVersion: + """The API version under test.""" + return MAX_SUPPORTED_VERSION + + @pytest.fixture def csv_parameter_subject(decoy: Decoy) -> CSVParameterDefinition: """Return a CSV Parameter Definition subject.""" @@ -55,12 +62,9 @@ def test_create_csv_parameter(decoy: Decoy) -> None: def test_set_csv_value( decoy: Decoy, csv_parameter_subject: CSVParameterDefinition ) -> None: - """It should set the CSV parameter value to a file.""" - mock_file = decoy.mock(cls=TextIOWrapper) - decoy.when(mock_file.name).then_return("mock.csv") - - csv_parameter_subject.value = mock_file - assert csv_parameter_subject.value is mock_file + """It should set the CSV parameter value to a path.""" + csv_parameter_subject.value = Path("123") + assert csv_parameter_subject.value == Path("123") def test_csv_parameter_as_protocol_engine_type( @@ -86,14 +90,14 @@ def test_csv_parameter_as_protocol_engine_type( def test_csv_parameter_as_csv_parameter_interface( + api_version: APIVersion, csv_parameter_subject: CSVParameterDefinition, ) -> None: """It should return the CSV parameter interface for use in a protocol run context.""" - result = csv_parameter_subject.as_csv_parameter_interface() + result = csv_parameter_subject.as_csv_parameter_interface(api_version) with pytest.raises(RuntimeParameterRequired): result.file - mock_file = tempfile.NamedTemporaryFile(mode="r", suffix=".csv") - csv_parameter_subject.value = mock_file # type: ignore[assignment] - result = csv_parameter_subject.as_csv_parameter_interface() - assert result.file is mock_file # type: ignore[comparison-overlap] + csv_parameter_subject.value = Path("abc") + result = csv_parameter_subject.as_csv_parameter_interface(api_version) + assert result._path == Path("abc") diff --git a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py index be46b61845d..4cd9e649b63 100644 --- a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py +++ b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py @@ -1,60 +1,95 @@ import pytest +import inspect +from decoy import Decoy from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] import tempfile -from typing import TextIO +from pathlib import Path +from typing import TextIO, Generator -from opentrons.protocols.parameters.types import CSVParameter +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION +from opentrons.protocols.parameters import ( + parameter_file_reader as mock_param_file_reader, +) +from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter + + +@pytest.fixture(autouse=True) +def _patch_parameter_file_reader(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: + for name, func in inspect.getmembers(mock_param_file_reader, inspect.isfunction): + monkeypatch.setattr(mock_param_file_reader, name, decoy.mock(func=func)) + + +@pytest.fixture +def api_version() -> APIVersion: + """The API version under test.""" + return MAX_SUPPORTED_VERSION + + +@pytest.fixture +def csv_file_basic() -> Generator[TextIO, None, None]: + """A basic CSV file with quotes around strings.""" + with tempfile.TemporaryFile("r+") as temp_file: + contents = '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' + temp_file.write(contents) + temp_file.seek(0) + yield temp_file @pytest.fixture -def csv_file_basic() -> TextIO: - temp_file = tempfile.TemporaryFile("r+") - contents = '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' - temp_file.write(contents) - temp_file.seek(0) - return temp_file +def csv_file_no_quotes() -> Generator[TextIO, None, None]: + """A basic CSV file with no quotes around strings.""" + with tempfile.TemporaryFile("r+") as temp_file: + contents = "x,y,z\na,1,2\nb,3,4\nc,5,6" + temp_file.write(contents) + temp_file.seek(0) + yield temp_file @pytest.fixture -def csv_file_no_quotes() -> TextIO: - temp_file = tempfile.TemporaryFile("r+") - contents = "x,y,z\na,1,2\nb,3,4\nc,5,6" - temp_file.write(contents) - temp_file.seek(0) - return temp_file +def csv_file_preceding_spaces() -> Generator[TextIO, None, None]: + """A basic CSV file with quotes around strings and spaces preceding non-initial columns.""" + with tempfile.TemporaryFile("r+") as temp_file: + contents = '"x", "y", "z"\n"a", 1, 2\n"b", 3, 4\n"c", 5, 6' + temp_file.write(contents) + temp_file.seek(0) + yield temp_file @pytest.fixture -def csv_file_preceding_spaces() -> TextIO: - temp_file = tempfile.TemporaryFile("r+") - contents = '"x", "y", "z"\n"a", 1, 2\n"b", 3, 4\n"c", 5, 6' - temp_file.write(contents) - temp_file.seek(0) - return temp_file +def csv_file_mixed_quotes() -> Generator[TextIO, None, None]: + """A basic CSV file with both string quotes and escaped quotes.""" + with tempfile.TemporaryFile("r+") as temp_file: + contents = 'head,er\n"a,b,c",def\n"""ghi""","jkl"' + temp_file.write(contents) + temp_file.seek(0) + yield temp_file @pytest.fixture -def csv_file_mixed_quotes() -> TextIO: - temp_file = tempfile.TemporaryFile("r+") - contents = 'head,er\n"a,b,c",def\n"""ghi""","jkl"' - temp_file.write(contents) - temp_file.seek(0) - return temp_file +def csv_file_different_delimiter() -> Generator[TextIO, None, None]: + """A basic CSV file with a non-comma delimiter.""" + with tempfile.TemporaryFile("r+") as temp_file: + contents = "x:y:z\na,:1,:2\nb,:3,:4\nc,:5,:6" + temp_file.write(contents) + temp_file.seek(0) + yield temp_file @pytest.fixture -def csv_file_different_delimiter() -> TextIO: - temp_file = tempfile.TemporaryFile("r+") - contents = "x:y:z\na,:1,:2\nb,:3,:4\nc,:5,:6" - temp_file.write(contents) - temp_file.seek(0) - return temp_file +def subject(api_version: APIVersion) -> CSVParameter: + """Return a CSVParameter interface subject.""" + return CSVParameter(csv_path=Path("abc"), api_version=api_version) -def test_csv_parameter(csv_file_basic: TextIO) -> None: +def test_csv_parameter( + decoy: Decoy, csv_file_basic: TextIO, subject: CSVParameter +) -> None: """It should load the CSV parameter and provide access to the file, contents, and rows.""" - subject = CSVParameter(csv_file_basic) + decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( + csv_file_basic + ) assert subject.file is csv_file_basic assert subject.contents == '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' @@ -67,35 +102,49 @@ def test_csv_parameter(csv_file_basic: TextIO) -> None: lazy_fixture("csv_file_preceding_spaces"), ], ) -def test_csv_parameter_rows(csv_file: TextIO) -> None: +def test_csv_parameter_rows( + decoy: Decoy, csv_file: TextIO, subject: CSVParameter +) -> None: """It should load the rows as all strings even with no quotes or leading spaces.""" - subject = CSVParameter(csv_file) + decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return(csv_file) assert len(subject.parse_as_csv()) == 4 assert subject.parse_as_csv()[0] == ["x", "y", "z"] assert subject.parse_as_csv()[1] == ["a", "1", "2"] -def test_csv_parameter_mixed_quotes(csv_file_mixed_quotes: TextIO) -> None: +def test_csv_parameter_mixed_quotes( + decoy: Decoy, csv_file_mixed_quotes: TextIO, subject: CSVParameter +) -> None: """It should load the rows with no quotes, quotes and escaped quotes with double quotes.""" - subject = CSVParameter(csv_file_mixed_quotes) + decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( + csv_file_mixed_quotes + ) assert len(subject.parse_as_csv()) == 3 assert subject.parse_as_csv()[0] == ["head", "er"] assert subject.parse_as_csv()[1] == ["a,b,c", "def"] assert subject.parse_as_csv()[2] == ['"ghi"', "jkl"] -def test_csv_parameter_additional_kwargs(csv_file_different_delimiter: TextIO) -> None: +def test_csv_parameter_additional_kwargs( + decoy: Decoy, csv_file_different_delimiter: TextIO, subject: CSVParameter +) -> None: """It should load the rows with a different delimiter.""" - subject = CSVParameter(csv_file_different_delimiter) + decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( + csv_file_different_delimiter + ) rows = subject.parse_as_csv(delimiter=":") assert len(rows) == 4 assert rows[0] == ["x", "y", "z"] assert rows[1] == ["a,", "1,", "2"] -def test_csv_parameter_dont_detect_dialect(csv_file_preceding_spaces: TextIO) -> None: +def test_csv_parameter_dont_detect_dialect( + decoy: Decoy, csv_file_preceding_spaces: TextIO, subject: CSVParameter +) -> None: """It should load the rows without trying to detect the dialect.""" - subject = CSVParameter(csv_file_preceding_spaces) + decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( + csv_file_preceding_spaces + ) rows = subject.parse_as_csv(detect_dialect=False) assert rows[0] == ["x", ' "y"', ' "z"'] assert rows[1] == ["a", " 1", " 2"] diff --git a/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py b/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py new file mode 100644 index 00000000000..d469c827d08 --- /dev/null +++ b/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py @@ -0,0 +1,34 @@ +import pytest +import platform + +from opentrons_shared_data import get_shared_data_root, load_shared_data + +from opentrons.protocols.parameters.exceptions import RuntimeParameterRequired +from opentrons.protocols.parameters import parameter_file_reader as subject + + +def test_open_file_path() -> None: + """It should open a temporary file handler given a path.""" + contents = load_shared_data("protocol/fixtures/7/simpleV7.json") + shared_data_path = get_shared_data_root() / "protocol/fixtures/7/simpleV7.json" + + # On Windows, you can't open a NamedTemporaryFile a second time, which breaks the code under test. + # Because of the way CSV analysis works this code will only ever be run on the actual OT-2/Flex hardware, + # so we skip testing and instead assert that we get a PermissionError on Windows (to ensure this + # test gets fixed in case we ever refactor the file opening.) + if platform.system() != "Windows": + result = subject.open_file_path(shared_data_path) + + assert result.readable() + assert not result.writable() + assert result.read() == contents.decode("utf-8") + result.close() + else: + with pytest.raises(PermissionError): + subject.open_file_path(shared_data_path) + + +def test_open_file_path_raises() -> None: + """It should raise of no file path is provided.""" + with pytest.raises(RuntimeParameterRequired): + subject.open_file_path(None) diff --git a/api/tests/opentrons/test_simulate.py b/api/tests/opentrons/test_simulate.py index 6750bf850b0..6d5c96fc49c 100644 --- a/api/tests/opentrons/test_simulate.py +++ b/api/tests/opentrons/test_simulate.py @@ -296,6 +296,45 @@ def test_get_protocol_api_usable_without_homing(api_version: APIVersion) -> None pipette.pick_up_tip(tip_rack["A1"]) # Should not raise. +def test_liquid_probe_get_protocol_api() -> None: + """Covers `simulate.get_protocol_api()`-specific issues with liquid probes. + + See https://opentrons.atlassian.net/browse/EXEC-646. + """ + protocol = simulate.get_protocol_api(version="2.20", robot_type="Flex") + pipette = protocol.load_instrument("flex_1channel_1000", mount="left") + tip_rack = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "A1") + well_plate = protocol.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", "A2" + ) + pipette.pick_up_tip(tip_rack["A1"]) + pipette.require_liquid_presence(well_plate["A1"]) # Should not raise MustHomeError. + + +def test_liquid_probe_simulate_file() -> None: + """Covers `opentrons_simulate`-specific issues with liquid probes. + + See https://opentrons.atlassian.net/browse/EXEC-646. + """ + protocol_contents = textwrap.dedent( + """\ + requirements = {"robotType": "Flex", "apiLevel": "2.20"} + def run(protocol): + pipette = protocol.load_instrument("flex_1channel_1000", mount="left") + tip_rack = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "A1") + well_plate = protocol.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", "A2" + ) + pipette.pick_up_tip(tip_rack["A1"]) + pipette.require_liquid_presence(well_plate["A1"]) + """ + ) + protocol_contents_stream = io.StringIO(protocol_contents) + simulate.simulate( + protocol_file=protocol_contents_stream + ) # Should not raise MustHomeError. + + class TestGetProtocolAPILabware: """Tests for making sure get_protocol_api() handles extra labware correctly.""" diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index ffdf4fad357..fa21418bea2 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -4,6 +4,27 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr [technical change log]: https://github.com/Opentrons/opentrons/releases [opentrons issue tracker]: https://github.com/Opentrons/opentrons/issues?q=is%3Aopen+is%3Aissue+label%3Abug +By installing and using Opentrons software, you agree to the Opentrons End-User License Agreement (EULA). You can view the EULA at [opentrons.com/eula](https://opentrons.com/eula). + +--- + +## Opentrons App Changes in 8.0.0 + +Welcome to the v8.0.0 release of the Opentrons App! + +### New Features + +- Recover from errors during a protocol run on Flex. If certain types of errors occur, you will have the option to manually correct the error and resume your protocol. Follow detailed recovery instructions on the touchscreen or in the app. +- Perform quick transfers on Flex. Set up a new quick transfer directly on the touchscreen, specifying a tip rack and up to two labware for pipetting — no protocol file or coding required! You can save and reuse up to 20 quick transfers on a particular Flex robot. +- Use CSV files as runtime parameters. When setting up a protocol in the app, choose any file on your computer. Or on Flex, select from files already stored on the robot or on an attached USB drive. See the Python API documentation for more information on adding CSV capability to your protocols. + +### Improved Features + +- Run protocols using the latest version of the Python API (2.20), which adds more partial tip pickup configurations (Flex and OT-2 GEN2 pipettes) and the ability to detect whether a well contains liquid (Flex pipettes only). +- Tap or click on any labware on the deck map to see adapters and modules that are stacked below it. +- Lists of liquids now separately show the total volume and per-well volume (when it is the same in each well containing that liquid). +- Improved instructions for what to do when a Flex protocol completes or is canceled with liquid-filled tips attached to the pipette. + --- ## Opentrons App Changes in 7.5.0 diff --git a/app/src/DesignTokens/Typography/Typography.stories.tsx b/app/src/DesignTokens/Typography/Typography.stories.tsx index 76e4897f419..ac95fa3a369 100644 --- a/app/src/DesignTokens/Typography/Typography.stories.tsx +++ b/app/src/DesignTokens/Typography/Typography.stories.tsx @@ -26,6 +26,7 @@ const fontStyles = { ['BodyLarge', 'Regular'], ['BodyDefault', 'SemiBold'], ['BodyDefault', 'Regular'], + ['Caption', 'Bold'], ['Caption', 'SemiBold'], ['Caption', 'Regular'], ['Code', 'Regular'], diff --git a/app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png b/app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png new file mode 100644 index 00000000000..28a65ff766b Binary files /dev/null and b/app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png differ diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 41a6923112c..adbc00d3181 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -3,7 +3,6 @@ "__dev_internal__protocolStats": "Protocol Stats", "__dev_internal__protocolTimeline": "Protocol Timeline", "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", - "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index 4bedd4bc8e6..fe673268a50 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/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index f7eb5c5a565..f0ee3b0f4e0 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -33,6 +33,7 @@ "manually_fill_liquid_in_well": "Manually fill liquid in well {{well}}", "manually_fill_well_and_skip": "Manually fill well and skip to next step", "next_step": "Next step", + "next_try_another_action": "Next, you can try another recovery action or cancel the run.", "no_liquid_detected": "No liquid detected", "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly. If the issue persists, cancel the run and make the necessary changes to the protocol.", "pick_up_tips": "Pick up tips", @@ -54,7 +55,6 @@ "retry_with_same_tips": "Retry with same tips", "retrying_step_succeeded": "Retrying step {{step}} succeeded.", "return_to_menu": "Return to menu", - "return_to_the_menu": "Return to the menu to choose how to proceed.", "robot_door_is_open": "Robot door is open", "robot_is_canceling_run": "Robot is canceling the run", "robot_is_in_recovery_mode": "Robot is in recovery mode", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 8b495eae864..2a21ab64da1 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -8,6 +8,8 @@ "add_to_slot": "Add to slot {{slotName}}", "additional_labware": "{{count}} additional labware", "additional_off_deck_labware": "Additional Off-Deck Labware", + "applied_labware_offsets": "applied labware offsets", + "are_you_sure_you_want_to_proceed": "Are you sure you want to proceed to run?", "attach_gripper_failure_reason": "Attach the required gripper to continue", "attach_gripper": "attach gripper", "attach_module": "Attach module before calibrating", @@ -47,6 +49,9 @@ "configured": "configured", "confirm_heater_shaker_module_modal_description": "Before the run begins, module should have both anchors fully extended for a firm attachment. The thermal adapter should be attached to the module. ", "confirm_heater_shaker_module_modal_title": "Confirm Heater-Shaker Module is attached", + "confirm_offsets": "Confirm offsets", + "confirm_liquids": "Confirm liquids", + "confirm_placements": "Confirm placements", "confirm_selection": "Confirm selection", "confirm_values": "Confirm values", "connect_all_hardware": "Connect and calibrate all hardware first", @@ -85,6 +90,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", @@ -100,6 +106,7 @@ "labware_latch": "Labware Latch", "labware_location": "Labware Location", "labware_name": "Labware name", + "labware_placement": "labware placement", "labware_position_check_not_available_analyzing_on_robot": "Labware Position Check is not available while protocol is analyzing on robot", "labware_position_check_not_available_empty_protocol": "Labware Position Check requires that the protocol loads labware and pipettes", "labware_position_check_not_available": "Labware Position Check is not available after run has started", @@ -115,11 +122,15 @@ "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", + "liquids": "liquids", "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", "liquids_not_in_the_protocol": "no liquids are specified for this protocol.", - "liquids": "Liquids", + "liquids_ready": "Liquids ready", + "liquids_confirmed": "Liquids confirmed", "list_view": "List View", "loading_data": "Loading data...", "loading_labware_offsets": "Loading labware offsets", @@ -146,6 +157,7 @@ "module_name": "Module", "module_not_connected": "Not connected", "module_setup_step_title": "Deck hardware", + "module_setup_step_ready": "Calibration ready", "module_slot_location": "Slot {{slotName}}, {{moduleName}}", "module": "Module", "modules_connected_plural": "{{count}} modules attached", @@ -188,6 +200,7 @@ "offset_data": "Offset Data", "offsets_applied_plural": "{{count}} offsets applied", "offsets_applied": "{{count}} offset applied", + "offsets_ready": "Offsets ready", "on_adapter_in_mod": "on {{adapterName}} in {{moduleName}}", "on_adapter": "on {{adapterName}}", "on_deck": "On deck", @@ -203,6 +216,8 @@ "pipette_offset_cal_description": "This measures a pipette’s X, Y and Z values in relation to the pipette mount and the deck. Pipette Offset Calibration relies on Deck Calibration and Tip Length Calibration. ", "pipette_offset_cal": "Pipette Offset Calibration", "placement": "Placement", + "placements_ready": "Placements ready", + "placements_confirmed": "Placements confirmed", "plug_in_module_to_configure": "Plug in a {{module}} to add it to the slot", "plug_in_required_module_plural": "Plug in and power up the required modules to continue", "plug_in_required_module": "Plug in and power up the required module to continue", @@ -243,6 +258,7 @@ "robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.", "robot_calibration_step_description": "Review required pipettes and tip length calibrations for this protocol.", "robot_calibration_step_title": "Instruments", + "robot_calibration_step_ready": "Calibration ready", "run_disabled_calibration_not_complete": "Make sure robot calibration is complete before proceeding to run", "run_disabled_modules_and_calibration_not_complete": "Make sure robot calibration is complete and all modules are connected before proceeding to run", "run_disabled_modules_not_connected": "Make sure all modules are connected before proceeding to run", @@ -257,6 +273,8 @@ "setup_is_view_only": "Setup is view-only once run has started", "slot_location": "Slot {{slotName}}", "slot_number": "Slot Number", + "stacked_slot": "Stacked slot", + "start_run": "Start run", "status": "Status", "step": "STEP {{index}}", "there_are_no_unconfigured_modules": "No {{module}} is connected. Attach one and place it in {{slot}}.", @@ -265,8 +283,10 @@ "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", + "update_offsets": "Update offsets", "updated": "Updated", "usb_connected_no_port_info": "USB Port Connected", "usb_drive_notification": "Leave USB drive attached until run starts", @@ -276,10 +296,12 @@ "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", "volume": "Volume", "what_labware_offset_is": "A Labware Offset is a type of positional adjustment that accounts for small, real-world variances in the overall position of the labware on a robot’s deck. Labware Offset data is unique to a specific combination of labware definition, deck slot, and robot.", - "with_the_chosen_value": "With the chosen values, the following error occurred:" + "with_the_chosen_value": "With the chosen values, the following error occurred:", + "you_havent_confirmed": "You haven't confirmed the {{missingSteps}} yet. Ensure these are correct before proceeding to run the protocol." } diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index f40298d6ae1..b754376c81a 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -47,6 +47,7 @@ "deleted_transfer": "Deleted quick transfer", "destination": "Destination", "destination_labware": "Destination labware", + "disabled": "Disabled", "dispense_flow_rate": "Dispense flow rate", "dispense_flow_rate_µL": "Dispense flow rate (µL/s)", "dispense_settings": "Dispense Settings", @@ -90,7 +91,8 @@ "pipette_currently_attached": "Quick transfer options depend on the pipettes currently attached to your robot.", "pipette_path": "Pipette path", "pipette_path_multi_aspirate": "Multi-aspirate", - "pipette_path_multi_dispense": "Multi-dispense, {{volume}} disposal volume, blowout into {{blowOutLocation}}", + "pipette_path_multi_dispense": "Multi-dispense", + "pipette_path_multi_dispense_volume_blowout": "Multi-dispense, {{volume}} disposal volume, blowout into {{blowOutLocation}}", "pipette_path_single": "Single transfers", "pre_wet_tip": "Pre-wet tip", "quick_transfer": "Quick transfer", diff --git a/app/src/atoms/buttons/FloatingActionButton.tsx b/app/src/atoms/buttons/FloatingActionButton.tsx index b7745f26f12..5905bdd8fce 100644 --- a/app/src/atoms/buttons/FloatingActionButton.tsx +++ b/app/src/atoms/buttons/FloatingActionButton.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' import { @@ -12,29 +11,21 @@ import { Icon, POSITION_FIXED, SPACING, - LegacyStyledText, - TYPOGRAPHY, + StyledText, } from '@opentrons/components' -import type { IconName, StyleProps } from '@opentrons/components' +import type { IconName } from '@opentrons/components' -interface FloatingActionButtonProps extends StyleProps { - buttonText?: React.ReactNode +interface FloatingActionButtonProps extends React.ComponentProps { + buttonText: string disabled?: boolean iconName?: IconName - onClick: React.MouseEventHandler } export function FloatingActionButton( props: FloatingActionButtonProps ): JSX.Element { - const { t } = useTranslation('protocol_setup') - const { - buttonText = t('map_view'), - disabled = false, - iconName = 'deck-map', - ...buttonProps - } = props + const { buttonText, disabled = false, iconName, ...buttonProps } = props const contentColor = disabled ? COLORS.grey50 : COLORS.white const FLOATING_ACTION_BUTTON_STYLE = css` @@ -65,9 +56,6 @@ export function FloatingActionButton( bottom={SPACING.spacing24} css={FLOATING_ACTION_BUTTON_STYLE} disabled={disabled} - fontSize={TYPOGRAPHY.fontSize28} - fontWeight={TYPOGRAPHY.fontWeightSemiBold} - lineHeight={TYPOGRAPHY.lineHeight36} padding={`${SPACING.spacing12} ${SPACING.spacing24}`} position={POSITION_FIXED} right={SPACING.spacing24} @@ -78,13 +66,15 @@ export function FloatingActionButton( flexDirection={DIRECTION_ROW} gridGap={SPACING.spacing8} > - - {buttonText} + {iconName != null ? ( + + ) : null} + {buttonText} ) diff --git a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx index 5f325c34d88..4d479fd93cf 100644 --- a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx +++ b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx @@ -31,9 +31,6 @@ describe('FloatingActionButton', () => { `padding: ${SPACING.spacing12} ${SPACING.spacing24}` ) expect(button).toHaveStyle(`background-color: ${COLORS.purple50}`) - expect(button).toHaveStyle(`font-size: ${TYPOGRAPHY.fontSize28}`) - expect(button).toHaveStyle(`font-weight: ${TYPOGRAPHY.fontWeightSemiBold}`) - expect(button).toHaveStyle(`line-height: ${TYPOGRAPHY.lineHeight36}`) expect(button).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) expect(button).toHaveStyle( `text-transform: ${TYPOGRAPHY.textTransformNone}` diff --git a/app/src/molecules/OddModal/types.ts b/app/src/molecules/OddModal/types.ts index 940554658de..b0fa6d103ae 100644 --- a/app/src/molecules/OddModal/types.ts +++ b/app/src/molecules/OddModal/types.ts @@ -3,7 +3,7 @@ import type { IconName, StyleProps } from '@opentrons/components' export type ModalSize = 'small' | 'medium' | 'large' export interface OddModalHeaderBaseProps extends StyleProps { - title: string + title: string | JSX.Element onClick?: React.MouseEventHandler hasExitIcon?: boolean iconName?: IconName diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx index 61e6b6de67a..5e79b1ff8bd 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/ChooseRobotSlideout/FileCard.tsx b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx index 99d66bf54fe..0ed23c4aa2f 100644 --- a/app/src/organisms/ChooseRobotSlideout/FileCard.tsx +++ b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx @@ -51,7 +51,7 @@ export function FileCard(props: FileCardProps): JSX.Element { white-space: nowrap; `} > - {truncateString(fileRunTimeParameter?.file?.name ?? '', 35, 18)} + {truncateString(fileRunTimeParameter?.file?.file?.name ?? '', 35, 18)} parameter.type === 'csv_file' + ).length const runStatus = run.status const runDisplayName = formatTimestamp(run.createdAt) let duration = EMPTY_TIMESTAMP @@ -92,7 +91,7 @@ export function HistoricalProtocolRun( width="5%" data-testid={`RecentProtocolRuns_Files_${protocolKey}`} > - {allProtocolDataFiles.length} + {countRunDataFiles} new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) - const { data } = useAllCsvFilesQuery(run.protocolId ?? '') - const allProtocolDataFiles = data != null ? data.data : [] + const runDataFileIds = run.runTimeParameters.reduce( + (acc, parameter) => { + if (parameter.type === 'csv_file') { + return parameter.file?.id != null ? [...acc, parameter.file?.id] : acc + } + return acc + }, + [] + ) const uniqueLabwareOffsets = allLabwareOffsets?.filter( (offset, index, array) => { return ( @@ -94,7 +101,7 @@ export function HistoricalProtocolRunDrawer( ) : null const protocolFilesData = - allProtocolDataFiles.length === 1 ? ( + runDataFileIds.length === 0 ? ( ) : ( @@ -133,43 +140,8 @@ export function HistoricalProtocolRunDrawer( - {allProtocolDataFiles.map((fileData, index) => { - const { createdAt, name: fileName, id: fileId } = fileData - return ( - - - - {fileName} - - - - - {format(new Date(createdAt), 'M/d/yy HH:mm:ss')} - - - - - - - ) + {runDataFileIds.map((fileId, index) => { + return })} @@ -293,3 +265,47 @@ export function HistoricalProtocolRunDrawer( ) } + +interface CsvFileDataRowProps { + fileId: string +} + +function CsvFileDataRow(props: CsvFileDataRowProps): JSX.Element | null { + const { fileId } = props + + const { data: fileData } = useCsvFileQuery(fileId) + if (fileData == null) { + return null + } + const { name, createdAt } = fileData.data + return ( + + + + {name} + + + + + {format(new Date(createdAt), 'M/d/yy HH:mm:ss')} + + + + + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx new file mode 100644 index 00000000000..f4749aa06d1 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_FLEX_END, + PrimaryButton, + SecondaryButton, + SPACING, + LegacyStyledText, + TYPOGRAPHY, + Modal, +} from '@opentrons/components' + +interface ConfirmMissingStepsModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} +export const ConfirmMissingStepsModal = ( + props: ConfirmMissingStepsModalProps +): JSX.Element | null => { + const { missingSteps, onCloseClick, onConfirmClick } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + const confirmAttached = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps.map(step => t(step))), + })} + + + + + {i18n.format(t('shared:go_back'), 'capitalize')} + + + {t('start_run')} + + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 9f4bef400ee..809e1253620 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -23,6 +23,7 @@ import { useDoorQuery, useHost, useInstrumentsQuery, + useRunCommandErrors, } from '@opentrons/react-api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { @@ -77,6 +78,7 @@ import { } from '../../../organisms/RunTimeControl/hooks' import { useIsHeaterShakerInProtocol } from '../../ModuleCard/hooks' import { ConfirmAttachmentModal } from '../../ModuleCard/ConfirmAttachmentModal' +import { ConfirmMissingStepsModal } from './ConfirmMissingStepsModal' import { useProtocolDetailsForRun, useProtocolAnalysisErrors, @@ -111,7 +113,12 @@ import { ProtocolDropTipModal, } from './ProtocolDropTipModal' -import type { Run, RunError, RunStatus } from '@opentrons/api-client' +import type { + Run, + RunCommandErrors, + RunError, + RunStatus, +} from '@opentrons/api-client' import type { IconName } from '@opentrons/components' import type { State } from '../../../redux/types' import type { HeaterShakerModule } from '../../../redux/modules/types' @@ -132,6 +139,7 @@ interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void + missingSetupSteps: string[] } export function ProtocolRunHeader({ @@ -139,6 +147,7 @@ export function ProtocolRunHeader({ robotName, runId, makeHandleJumpToStep, + missingSetupSteps, }: ProtocolRunHeaderProps): JSX.Element | null { const { t } = useTranslation(['run_details', 'shared']) const navigate = useNavigate() @@ -163,6 +172,13 @@ export function ProtocolRunHeader({ const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const [showRunFailedModal, setShowRunFailedModal] = React.useState(false) + const { data: commandErrorList } = useRunCommandErrors(runId, null, { + enabled: + runStatus != null && + // @ts-expect-error runStatus expected to possibly not be terminal + RUN_STATUSES_TERMINAL.includes(runStatus) && + isRunCurrent, + }) const [showDropTipBanner, setShowDropTipBanner] = React.useState(true) const isResetRunLoadingRef = React.useRef(false) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) @@ -205,7 +221,7 @@ export function ProtocolRunHeader({ determineTipStatus, resetTipStatus, setTipStatusResolved, - pipettesWithTip, + aPipetteWithTip, } = useTipAttachmentStatus({ runId, runRecord, @@ -255,7 +271,6 @@ export function ProtocolRunHeader({ // Side effects dependent on the current run state. React.useEffect(() => { - // After a user-initiated stopped run, close the run current run automatically. if (runStatus === RUN_STATUS_STOPPED && isRunCurrent && runId != null) { trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.FINISH, @@ -263,9 +278,8 @@ export function ProtocolRunHeader({ ...robotAnalyticsData, }, }) - closeCurrentRun() } - }, [runStatus, isRunCurrent, runId, closeCurrentRun]) + }, [runStatus, isRunCurrent, runId]) const startedAtTimestamp = startedAt != null ? formatTimestamp(startedAt) : EMPTY_TIMESTAMP @@ -330,6 +344,7 @@ export function ProtocolRunHeader({ runId={runId} setShowRunFailedModal={setShowRunFailedModal} highestPriorityError={highestPriorityError} + commandErrorList={commandErrorList} /> ) : null} ) : null} @@ -447,6 +463,7 @@ export function ProtocolRunHeader({ isDoorOpen={isDoorOpen} isFixtureMismatch={isFixtureMismatch} isResetRunLoadingRef={isResetRunLoadingRef} + missingSetupSteps={missingSetupSteps} /> @@ -496,11 +513,11 @@ export function ProtocolRunHeader({ robotName={robotName} /> ) : null} - {showDTWiz && mostRecentRunId === runId ? ( + {showDTWiz && aPipetteWithTip != null ? ( setTipStatusResolved().then(toggleDTWiz)} /> ) : null} @@ -591,6 +608,7 @@ interface ActionButtonProps { isDoorOpen: boolean isFixtureMismatch: boolean isResetRunLoadingRef: React.MutableRefObject + missingSetupSteps: string[] } // TODO(jh, 04-22-2024): Refactor switch cases into separate factories to increase readability and testability. @@ -603,6 +621,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { isDoorOpen, isFixtureMismatch, isResetRunLoadingRef, + missingSetupSteps, } = props const navigate = useNavigate() const { t } = useTranslation(['run_details', 'shared']) @@ -682,12 +701,20 @@ function ActionButton(props: ActionButtonProps): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation ) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmationModal, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + missingSetupSteps.length !== 0 + ) const robotAnalyticsData = useRobotAnalyticsData(robotName) const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() @@ -745,6 +772,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { handleButtonClick = () => { if (isHeaterShakerShaking && isHeaterShakerInProtocol) { setShowIsShakingModal(true) + } else if ( + missingSetupSteps.length !== 0 && + (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + ) { + confirmMissingSteps() } else if ( isHeaterShakerInProtocol && !isHeaterShakerShaking && @@ -825,13 +857,21 @@ function ActionButton(props: ActionButtonProps): JSX.Element { startRun={play} /> )} - {showConfirmationModal && ( + {showHSConfirmationModal && ( )} + {showMissingStepsConfirmationModal && ( + + )} + {} ) } @@ -842,6 +882,7 @@ interface TerminalRunProps { handleClearClick: () => void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void + commandErrorList?: RunCommandErrors isResetRunLoading: boolean isRunCurrent: boolean highestPriorityError?: RunError | null @@ -852,6 +893,7 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { handleClearClick, isClosingCurrentRun, setShowRunFailedModal, + commandErrorList, highestPriorityError, isResetRunLoading, isRunCurrent, @@ -887,10 +929,12 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { - {t('error_info', { - errorType: highestPriorityError?.errorType, - errorCode: highestPriorityError?.errorCode, - })} + {highestPriorityError != null + ? t('error_info', { + errorType: highestPriorityError?.errorType, + errorCode: highestPriorityError?.errorCode, + }) + : 'Run completed with errors.'} 0 && + !isResetRunLoading) + ) { return buildErrorBanner() } else { return null diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index ab436e5973f..dcec582de1d 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/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 19c29827c15..7ea1386768d 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -16,6 +16,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + FLEX_MAX_CONTENT, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' @@ -48,8 +49,6 @@ import { SetupLiquids } from './SetupLiquids' import { EmptySetupStep } from './EmptySetupStep' import { HowLPCWorksModal } from './SetupLabwarePositionCheck/HowLPCWorksModal' -import type { ProtocolCalibrationStatus } from '../hooks' - const ROBOT_CALIBRATION_STEP_KEY = 'robot_calibration_step' as const const MODULE_SETUP_KEY = 'module_setup_step' as const const LPC_KEY = 'labware_position_check_step' as const @@ -63,16 +62,33 @@ export type StepKey = | typeof LABWARE_SETUP_KEY | typeof LIQUID_SETUP_KEY +export type MissingStep = + | 'applied_labware_offsets' + | 'labware_placement' + | 'liquids' + +export type MissingSteps = MissingStep[] + +export const initialMissingSteps = (): MissingSteps => [ + 'applied_labware_offsets', + 'labware_placement', + 'liquids', +] + interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string + setMissingSteps: (missingSteps: MissingSteps) => void + missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, + setMissingSteps, + missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) @@ -147,6 +163,15 @@ export function ProtocolRunSetup({ return true }) + const [ + labwareSetupComplete, + setLabwareSetupComplete, + ] = React.useState(false) + const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( + false + ) + const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) return null const liquids = protocolAnalysis?.liquids ?? [] @@ -171,7 +196,11 @@ export function ProtocolRunSetup({ const StepDetailMap: Record< StepKey, - { stepInternals: JSX.Element; description: string } + { + stepInternals: JSX.Element + description: string + rightElProps: StepRightElementProps + } > = { [ROBOT_CALIBRATION_STEP_KEY]: { stepInternals: ( @@ -193,6 +222,15 @@ export function ProtocolRunSetup({ description: isFlex ? t(`${ROBOT_CALIBRATION_STEP_KEY}_description_pipettes_only`) : t(`${ROBOT_CALIBRATION_STEP_KEY}_description`), + rightElProps: { + stepKey: ROBOT_CALIBRATION_STEP_KEY, + complete: calibrationStatusRobot.complete, + completeText: t('calibration_ready'), + missingHardware: isMissingPipette, + incompleteText: t('calibration_needed'), + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [MODULE_SETUP_KEY]: { stepInternals: ( @@ -209,47 +247,99 @@ export function ProtocolRunSetup({ description: isFlex ? flexDeckHardwareDescription : ot2DeckHardwareDescription, + rightElProps: { + stepKey: MODULE_SETUP_KEY, + complete: + calibrationStatusRobot.complete && calibrationStatusModules.complete, + completeText: isFlex ? t('calibration_ready') : '', + incompleteText: isFlex ? t('calibration_needed') : t('action_needed'), + missingHardware: isMissingModule || isFixtureMismatch, + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [LPC_KEY]: { stepInternals: ( { - setExpandedStepKey(LABWARE_SETUP_KEY) + setOffsetsConfirmed={confirmed => { + setLpcComplete(confirmed) + if (confirmed) { + setExpandedStepKey(LABWARE_SETUP_KEY) + setMissingSteps( + missingSteps.filter(step => step !== 'applied_labware_offsets') + ) + } }} + offsetsConfirmed={lpcComplete} /> ), description: t('labware_position_check_step_description'), + rightElProps: { + stepKey: LPC_KEY, + complete: lpcComplete, + completeText: t('offsets_ready'), + incompleteText: null, + incompleteElement: , + }, }, [LABWARE_SETUP_KEY]: { stepInternals: ( v === LABWARE_SETUP_KEY) === - targetStepKeyInOrder.length - 1 - ? null - : LIQUID_SETUP_KEY - } - expandStep={setExpandedStepKey} + labwareConfirmed={labwareSetupComplete} + setLabwareConfirmed={(confirmed: boolean) => { + setLabwareSetupComplete(confirmed) + if (confirmed) { + setMissingSteps( + missingSteps.filter(step => step !== 'labware_placement') + ) + const nextStep = + targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === + targetStepKeyInOrder.length - 1 + ? null + : LIQUID_SETUP_KEY + setExpandedStepKey(nextStep) + } + }} /> ), description: t(`${LABWARE_SETUP_KEY}_description`), + rightElProps: { + stepKey: LABWARE_SETUP_KEY, + complete: labwareSetupComplete, + completeText: t('placements_ready'), + incompleteText: null, + incompleteElement: null, + }, }, [LIQUID_SETUP_KEY]: { stepInternals: ( { + setLiquidSetupComplete(confirmed) + if (confirmed) { + setMissingSteps(missingSteps.filter(step => step !== 'liquids')) + setExpandedStepKey(null) + } + }} /> ), description: hasLiquids ? t(`${LIQUID_SETUP_KEY}_description`) : i18n.format(t('liquids_not_in_the_protocol'), 'capitalize'), + rightElProps: { + stepKey: LIQUID_SETUP_KEY, + complete: liquidSetupComplete, + completeText: t('liquids_ready'), + incompleteText: null, + incompleteElement: null, + }, }, } @@ -295,17 +385,7 @@ export function ProtocolRunSetup({ }} rightElement={ } > @@ -329,81 +409,110 @@ export function ProtocolRunSetup({ ) } -interface StepRightElementProps { - stepKey: StepKey - calibrationStatusRobot: ProtocolCalibrationStatus - calibrationStatusModules?: ProtocolCalibrationStatus - runHasStarted: boolean - isFlex: boolean - isMissingModule: boolean - isFixtureMismatch: boolean - isMissingPipette: boolean +interface NoHardwareRequiredStepCompletion { + stepKey: Exclude< + StepKey, + typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + > + complete: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string +} + +interface HardwareRequiredStepCompletion { + stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + complete: boolean + missingHardware: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string + missingHardwareText: string } -function StepRightElement(props: StepRightElementProps): JSX.Element | null { - const { - stepKey, - runHasStarted, - calibrationStatusRobot, - calibrationStatusModules, - isFlex, - isMissingModule, - isFixtureMismatch, - isMissingPipette, - } = props - const { t } = useTranslation('protocol_setup') - const isActionNeeded = isMissingModule || isFixtureMismatch - if ( - !runHasStarted && - (stepKey === ROBOT_CALIBRATION_STEP_KEY || stepKey === MODULE_SETUP_KEY) - ) { - const moduleAndDeckStatus = isActionNeeded - ? { complete: false } - : calibrationStatusModules - const calibrationStatus = - stepKey === ROBOT_CALIBRATION_STEP_KEY - ? calibrationStatusRobot - : moduleAndDeckStatus +type StepRightElementProps = + | NoHardwareRequiredStepCompletion + | HardwareRequiredStepCompletion - let statusText = t('calibration_ready') - if ( - stepKey === ROBOT_CALIBRATION_STEP_KEY && - !calibrationStatusRobot.complete - ) { - statusText = isMissingPipette - ? t('action_needed') - : t('calibration_needed') - } else if (stepKey === MODULE_SETUP_KEY && !calibrationStatus?.complete) { - statusText = isActionNeeded ? t('action_needed') : t('calibration_needed') - } +const stepRequiresHW = ( + props: StepRightElementProps +): props is HardwareRequiredStepCompletion => + props.stepKey === ROBOT_CALIBRATION_STEP_KEY || + props.stepKey === MODULE_SETUP_KEY - // do not render calibration ready status icon for OT-2 module setup - return isFlex || - !( - stepKey === MODULE_SETUP_KEY && statusText === t('calibration_ready') - ) ? ( +function StepRightElement(props: StepRightElementProps): JSX.Element | null { + if (props.complete) { + return ( + + + + {props.completeText} + + + ) + } else if (stepRequiresHW(props) && props.missingHardware) { + return ( + + + + {props.missingHardwareText} + + + ) + } else if (props.incompleteText != null) { + return ( - {statusText} + {props.incompleteText} - ) : null - } else if (stepKey === LPC_KEY) { - return + ) + } else if (props.incompleteElement != null) { + return props.incompleteElement } else { return null } diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index 030f2a29d86..7d46206db0b 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -23,8 +23,9 @@ import { import { useDownloadRunLog } from '../hooks' -import type { RunError } from '@opentrons/api-client' import type { ModalProps } from '@opentrons/components' +import type { RunError, RunCommandErrors } from '@opentrons/api-client' +import type { RunCommandError } from '@opentrons/shared-data' /** * This modal is for Desktop app @@ -43,6 +44,7 @@ interface RunFailedModalProps { runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void highestPriorityError?: RunError | null + commandErrorList?: RunCommandErrors | null } export function RunFailedModal({ @@ -50,6 +52,7 @@ export function RunFailedModal({ runId, setShowRunFailedModal, highestPriorityError, + commandErrorList, }: RunFailedModalProps): JSX.Element | null { const { i18n, t } = useTranslation(['run_details', 'shared', 'branded']) const modalProps: ModalProps = { @@ -64,7 +67,7 @@ export function RunFailedModal({ } const { downloadRunLog } = useDownloadRunLog(robotName, runId) - if (highestPriorityError == null) return null + if (highestPriorityError == null && commandErrorList == null) return null const handleClick = (): void => { setShowRunFailedModal(false) @@ -76,20 +79,56 @@ export function RunFailedModal({ downloadRunLog() } - return ( - - + interface ErrorContentProps { + errors: RunCommandError[] + isSingleError: boolean + } + const ErrorContent = ({ + errors, + isSingleError, + }: ErrorContentProps): JSX.Element => { + return ( + <> - {t('error_info', { - errorType: highestPriorityError.errorType, - errorCode: highestPriorityError.errorCode, - })} + {isSingleError + ? t('error_info', { + errorType: errors[0].errorType, + errorCode: errors[0].errorCode, + }) + : `${errors.length} errors`} - - {highestPriorityError.detail} - + {' '} + {errors.map((error, index) => ( + + {' '} + {isSingleError + ? error.detail + : `${error.errorCode}: ${error.detail}`} + + ))} + + ) + } + + return ( + + + 0 + ? commandErrorList?.data + : [] + } + isSingleError={!!highestPriorityError} + /> {t('branded:run_failed_modal_description_desktop')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx new file mode 100644 index 00000000000..1a19ed2f5a9 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -0,0 +1,276 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { css } from 'styled-components' +import { + ALIGN_CENTER, + Box, + COLORS, + DeckInfoLabel, + DIRECTION_COLUMN, + Flex, + JUSTIFY_CENTER, + JUSTIFY_SPACE_BETWEEN, + LabwareStackRender, + SPACING, + StyledText, + Modal, +} from '@opentrons/components' +import { OddModal } from '../../../../molecules/OddModal' +import { getIsOnDevice } from '../../../../redux/config' +import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { getLocationInfoNames } from '../utils/getLocationInfoNames' +import { getSlotLabwareDefinition } from '../utils/getSlotLabwareDefinition' +import { Divider } from '../../../../atoms/structure' +import { getModuleImage } from '../SetupModuleAndDeck/utils' +import { + FLEX_ROBOT_TYPE, + getModuleDisplayName, + getModuleType, + TC_MODULE_LOCATION_OT2, + TC_MODULE_LOCATION_OT3, +} from '@opentrons/shared-data' +import tiprackAdapter from '../../../../assets/images/labware/opentrons_flex_96_tiprack_adapter.png' + +import type { RobotType } from '@opentrons/shared-data' + +const HIDE_SCROLLBAR = css` + ::-webkit-scrollbar { + display: none; + } +` + +interface LabwareStackModalProps { + labwareIdTop: string + runId: string + closeModal: () => void + robotType?: RobotType +} + +export const LabwareStackModal = ( + props: LabwareStackModalProps +): JSX.Element | null => { + const { labwareIdTop, runId, closeModal, robotType = FLEX_ROBOT_TYPE } = props + const { t } = useTranslation('protocol_setup') + const isOnDevice = useSelector(getIsOnDevice) + const protocolData = useMostRecentCompletedAnalysis(runId) + if (protocolData == null) { + return null + } + const commands = protocolData?.commands ?? [] + const { + slotName, + adapterName, + adapterId, + moduleModel, + labwareName, + labwareNickname, + } = getLocationInfoNames(labwareIdTop, commands) + + const topDefinition = getSlotLabwareDefinition(labwareIdTop, commands) + const adapterDef = getSlotLabwareDefinition(adapterId ?? '', commands) + const isModuleThermocycler = + moduleModel == null + ? false + : getModuleType(moduleModel) === 'thermocyclerModuleType' + const thermocyclerLocation = + robotType === FLEX_ROBOT_TYPE + ? TC_MODULE_LOCATION_OT3 + : TC_MODULE_LOCATION_OT2 + const moduleDisplayName = + moduleModel != null ? getModuleDisplayName(moduleModel) : null ?? '' + const tiprackAdapterImg = ( + + ) + const moduleImg = + moduleModel != null ? ( + + ) : null + + return isOnDevice ? ( + + + + + ), + onClick: closeModal, + }} + > + + <> + + + + + + + {adapterDef != null ? ( + <> + + + {adapterDef.parameters.loadName === + 'opentrons_flex_96_tiprack_adapter' ? ( + tiprackAdapterImg + ) : ( + + )} + + {moduleModel != null ? ( + + ) : null} + + ) : null} + {moduleModel != null ? ( + + + {moduleImg} + + ) : null} + + + ) : ( + + } + titleElement2={} + childrenPadding={0} + marginLeft="0" + > + + + <> + + + + + + + {adapterDef != null ? ( + <> + + + + + + + ) : null} + {moduleModel != null ? ( + + + {moduleImg} + + ) : null} + + + + ) +} + +interface LabwareStackLabelProps { + text: string + subText?: string + isOnDevice?: boolean +} +function LabwareStackLabel(props: LabwareStackLabelProps): JSX.Element { + const { text, subText, isOnDevice = false } = props + return isOnDevice ? ( + + {text} + {subText != null ? ( + + {subText} + + ) : null} + + ) : ( + + {text} + {subText != null ? ( + + {subText} + + ) : null} + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 533f134590d..8a35d8d203e 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -27,6 +27,7 @@ import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, } from '@opentrons/shared-data' +import { LabwareStackModal } from './LabwareStackModal' interface SetupLabwareMapProps { runId: string @@ -38,6 +39,14 @@ export function SetupLabwareMap({ protocolAnalysis, }: SetupLabwareMapProps): JSX.Element | null { // early return null if no protocol analysis + const [ + labwareStackDetailsLabwareId, + setLabwareStackDetailsLabwareId, + ] = React.useState(null) + const [hoverLabwareId, setHoverLabwareId] = React.useState( + null + ) + if (protocolAnalysis == null) return null const commands = protocolAnalysis.commands @@ -75,8 +84,29 @@ export function SetupLabwareMap({ : {}, nestedLabwareDef: topLabwareDefinition, + highlightLabware: + topLabwareDefinition != null && + topLabwareId != null && + hoverLabwareId === topLabwareId, + stacked: topLabwareDefinition != null && topLabwareId != null, moduleChildren: ( - <> + // open modal + { + if (topLabwareDefinition != null && topLabwareId != null) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + onMouseEnter={() => { + if (topLabwareDefinition != null && topLabwareId != null) { + setHoverLabwareId(topLabwareId) + } + }} + onMouseLeave={() => { + setHoverLabwareId(null) + }} + cursor="pointer" + > {topLabwareDefinition != null && topLabwareId != null ? ( ) : null} - + ), } }) @@ -143,6 +173,16 @@ export function SetupLabwareMap({ commands={commands} /> + {labwareStackDetailsLabwareId != null && ( + { + setLabwareStackDetailsLabwareId(null) + }} + robotType={robotType} + /> + )} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx index d6a6ab4b05e..e92169bcb1d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx @@ -35,14 +35,17 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let labwareConfirmed = false + const confirmLabware = vi.fn(confirmed => { + labwareConfirmed = confirmed + }) return renderWithProviders( , { diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx index 66b7bcdc1bc..526b944f425 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx @@ -16,22 +16,18 @@ import { useModuleRenderInfoForProtocolById, useStoredProtocolAnalysis, } from '../../hooks' -import { BackToTopButton } from '../BackToTopButton' import { SetupLabwareMap } from './SetupLabwareMap' import { SetupLabwareList } from './SetupLabwareList' -import type { StepKey } from '../ProtocolRunSetup' - interface SetupLabwareProps { - protocolRunHeaderRef: React.RefObject | null robotName: string runId: string - nextStep: StepKey | null - expandStep: (step: StepKey) => void + labwareConfirmed: boolean + setLabwareConfirmed: (confirmed: boolean) => void } export function SetupLabware(props: SetupLabwareProps): JSX.Element { - const { robotName, runId, nextStep, expandStep, protocolRunHeaderRef } = props + const { robotName, runId, labwareConfirmed, setLabwareConfirmed } = props const { t } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) @@ -71,22 +67,14 @@ export function SetupLabware(props: SetupLabwareProps): JSX.Element { )} - {nextStep == null ? ( - - ) : ( - { - expandStep(nextStep) - }} - > - {t('proceed_to_liquid_setup_step')} - - )} + { + setLabwareConfirmed(true) + }} + disabled={labwareConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx index 0bf4aaebbfc..0c0150937ad 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx @@ -42,10 +42,15 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let areOffsetsConfirmed = false + const confirmOffsets = vi.fn((offsetsConfirmed: boolean) => { + areOffsetsConfirmed = offsetsConfirmed + }) return renderWithProviders( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index 66484717ef0..21862539e35 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -32,7 +32,8 @@ import { useNotifyRunQuery } from '../../../../resources/runs' import type { LabwareOffset } from '@opentrons/api-client' interface SetupLabwarePositionCheckProps { - expandLabwareStep: () => void + offsetsConfirmed: boolean + setOffsetsConfirmed: (confirmed: boolean) => void robotName: string runId: string } @@ -40,7 +41,7 @@ interface SetupLabwarePositionCheckProps { export function SetupLabwarePositionCheck( props: SetupLabwarePositionCheckProps ): JSX.Element { - const { robotName, runId, expandLabwareStep } = props + const { robotName, runId, setOffsetsConfirmed, offsetsConfirmed } = props const { t, i18n } = useTranslation('protocol_setup') const robotType = useRobotType(robotName) @@ -75,7 +76,13 @@ export function SetupLabwarePositionCheck( const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis - const [targetProps, tooltipProps] = useHoverTooltip({ + const [runLPCTargetProps, runLPCTooltipProps] = useHoverTooltip({ + placement: TOOLTIP_LEFT, + }) + const [ + confirmOffsetsTargetProps, + confirmOffsetsTooltipProps, + ] = useHoverTooltip({ placement: TOOLTIP_LEFT, }) @@ -114,6 +121,22 @@ export function SetupLabwarePositionCheck( )} { + setOffsetsConfirmed(true) + }} + id="LPC_setOffsetsConfirmed" + padding={`${SPACING.spacing8} ${SPACING.spacing16}`} + {...confirmOffsetsTargetProps} + disabled={offsetsConfirmed || lpcDisabledReason !== null} + > + {t('confirm_offsets')} + + {lpcDisabledReason !== null ? ( + + {lpcDisabledReason} + + ) : null} + { @@ -121,21 +144,16 @@ export function SetupLabwarePositionCheck( setIsShowingLPCSuccessToast(false) }} id="LabwareSetup_checkLabwarePositionsButton" - {...targetProps} + {...runLPCTargetProps} disabled={lpcDisabledReason !== null} > {t('run_labware_position_check')} - + {lpcDisabledReason !== null ? ( - {lpcDisabledReason} + + {lpcDisabledReason} + ) : null} - - {t('proceed_to_labware_setup_step')} - {LPCWizard} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx index 58bf6cd951a..9f9dcf62df3 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/SetupLiquidsMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 4a73d6a3906..3bfd0d9c294 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -108,7 +108,9 @@ export function SetupLiquidsMap( setHoverLabwareId('') }} onClick={() => { - if (labwareHasLiquid) setLiquidDetailsLabwareId(topLabwareId) + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } }} cursor={labwareHasLiquid ? 'pointer' : ''} > @@ -169,8 +171,9 @@ export function SetupLiquidsMap( setHoverLabwareId('') }} onClick={() => { - if (labwareHasLiquid) + if (labwareHasLiquid) { setLiquidDetailsLabwareId(topLabwareId) + } }} cursor={labwareHasLiquid ? 'pointer' : ''} > diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx index 1c3dc33181e..06e48c49738 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx @@ -7,27 +7,35 @@ import { i18n } from '../../../../../i18n' import { SetupLiquids } from '../index' import { SetupLiquidsList } from '../SetupLiquidsList' import { SetupLiquidsMap } from '../SetupLiquidsMap' -import { BackToTopButton } from '../../BackToTopButton' vi.mock('../SetupLiquidsList') vi.mock('../SetupLiquidsMap') -vi.mock('../../BackToTopButton') -const render = (props: React.ComponentProps) => { - return renderWithProviders( - , - { - i18nInstance: i18n, +describe('SetupLiquids', () => { + const render = ( + props: React.ComponentProps & { + startConfirmed?: boolean } - ) -} + ) => { + let isConfirmed = + props?.startConfirmed == null ? false : props.startConfirmed + const confirmFn = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed + }) + return renderWithProviders( + , + { + i18nInstance: i18n, + } + ) + } -describe('SetupLiquids', () => { let props: React.ComponentProps beforeEach(() => { vi.mocked(SetupLiquidsList).mockReturnValue( @@ -36,16 +44,13 @@ describe('SetupLiquids', () => { vi.mocked(SetupLiquidsMap).mockReturnValue(
Mock setup liquids map
) - vi.mocked(BackToTopButton).mockReturnValue( - - ) }) it('renders the list and map view buttons and proceed button', () => { render(props) screen.getByRole('button', { name: 'List View' }) screen.getByRole('button', { name: 'Map View' }) - screen.getByRole('button', { name: 'Mock BackToTopButton' }) + screen.getByRole('button', { name: 'Confirm placements' }) }) it('renders the map view when you press that toggle button', () => { render(props) 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 4d50b071908..476896dfa3d 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 ed563673775..243bfeb3ed6 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx @@ -6,10 +6,10 @@ import { SPACING, DIRECTION_COLUMN, ALIGN_CENTER, + PrimaryButton, } from '@opentrons/components' import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' import { ANALYTICS_LIQUID_SETUP_VIEW_TOGGLE } from '../../../../redux/analytics' -import { BackToTopButton } from '../BackToTopButton' import { SetupLiquidsList } from './SetupLiquidsList' import { SetupLiquidsMap } from './SetupLiquidsMap' @@ -19,17 +19,19 @@ import type { } from '@opentrons/shared-data' interface SetupLiquidsProps { - protocolRunHeaderRef: React.RefObject | null - robotName: string runId: string protocolAnalysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null + isLiquidSetupConfirmed: boolean + setLiquidSetupConfirmed: (confirmed: boolean) => void + robotName: string } export function SetupLiquids({ - protocolRunHeaderRef, - robotName, runId, protocolAnalysis, + isLiquidSetupConfirmed, + setLiquidSetupConfirmed, + robotName, }: SetupLiquidsProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [selectedValue, toggleGroup] = useToggleGroup( @@ -46,17 +48,19 @@ export function SetupLiquids({ > {toggleGroup} {selectedValue === t('list_view') ? ( - + ) : ( )} - + { + setLiquidSetupConfirmed(true) + }} + disabled={isLiquidSetupConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/utils.ts index f7bf94adebc..a4b5f28a6f4 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/SetupModuleAndDeck/__tests__/utils.test.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts index 6a86b6daf55..2e4639a3c98 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts @@ -9,6 +9,13 @@ describe('getModuleImage', () => { ) }) + it('should render the high res magnetic module image when the model is a magnetic module gen 1 high res', () => { + const result = getModuleImage('magneticModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/magneticModuleV2@3x.png' + ) + }) + it('should render the magnetic module image when the model is a magnetic module gen 2', () => { const result = getModuleImage('magneticModuleV2') expect(result).toEqual( @@ -30,6 +37,13 @@ describe('getModuleImage', () => { ) }) + it('should render the high res temperature module image when the model is a temperature module high res', () => { + const result = getModuleImage('temperatureModuleV2', true) + expect(result).toEqual( + '/app/src/assets/images/modules/temperatureModuleV2@3x.png' + ) + }) + it('should render the heater-shaker module image when the model is a heater-shaker module gen 1', () => { const result = getModuleImage('heaterShakerModuleV1') expect(result).toEqual( @@ -37,11 +51,25 @@ describe('getModuleImage', () => { ) }) + it('should render the high res heater-shaker module image when the model is a heater-shaker module gen 1 high res', () => { + const result = getModuleImage('heaterShakerModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/heaterShakerModuleV1@3x.png' + ) + }) + it('should render the thermocycler module image when the model is a thermocycler module gen 1', () => { const result = getModuleImage('thermocyclerModuleV1') expect(result).toEqual('/app/src/assets/images/thermocycler_closed.png') }) + it('should render the high res thermocycler module image when the model is a thermocycler module gen 1 high res', () => { + const result = getModuleImage('thermocyclerModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/thermocyclerModuleV1@3x.png' + ) + }) + it('should render the thermocycler module image when the model is a thermocycler module gen 2', () => { const result = getModuleImage('thermocyclerModuleV2') expect(result).toEqual( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts index c5ac5c7984e..f5bd5187ad1 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts @@ -15,6 +15,10 @@ import magneticModule from '../../../../assets/images/magnetic_module_gen_2_tran import temperatureModule from '../../../../assets/images/temp_deck_gen_2_transparent.png' import thermoModuleGen1 from '../../../../assets/images/thermocycler_closed.png' import heaterShakerModule from '../../../../assets/images/heater_shaker_module_transparent.png' +import magneticModuleHighRes from '../../../../assets/images/modules/magneticModuleV2@3x.png' +import temperatureModuleHighRes from '../../../../assets/images/modules/temperatureModuleV2@3x.png' +import thermoModuleGen1HighRes from '../../../../assets/images/modules/thermocyclerModuleV1@3x.png' +import heaterShakerModuleHighRes from '../../../../assets/images/modules/heaterShakerModuleV1@3x.png' import thermoModuleGen2 from '../../../../assets/images/thermocycler_gen_2_closed.png' import magneticBlockGen1 from '../../../../assets/images/magnetic_block_gen_1.png' import stagingAreaMagneticBlockGen1 from '../../../../assets/images/staging_area_magnetic_block_gen_1.png' @@ -25,18 +29,21 @@ import wasteChuteStagingArea from '../../../../assets/images/waste_chute_with_st import type { CutoutFixtureId, ModuleModel } from '@opentrons/shared-data' -export function getModuleImage(model: ModuleModel): string { +export function getModuleImage( + model: ModuleModel, + highRes: boolean = false +): string { switch (model) { case 'magneticModuleV1': case 'magneticModuleV2': - return magneticModule + return highRes ? magneticModuleHighRes : magneticModule case 'temperatureModuleV1': case 'temperatureModuleV2': - return temperatureModule + return highRes ? temperatureModuleHighRes : temperatureModule case 'heaterShakerModuleV1': - return heaterShakerModule + return highRes ? heaterShakerModuleHighRes : heaterShakerModule case 'thermocyclerModuleV1': - return thermoModuleGen1 + return highRes ? thermoModuleGen1HighRes : thermoModuleGen1 case 'thermocyclerModuleV2': return thermoModuleGen2 case 'magneticBlockV1': diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 157538c9ff8..45f84024b49 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, @@ -24,6 +23,7 @@ import { useEstopQuery, useDoorQuery, useInstrumentsQuery, + useRunCommandErrors, } from '@opentrons/react-api-client' import { getPipetteModelSpecs, @@ -88,6 +88,7 @@ import { useNotifyRunQuery, useCurrentRunId } from '../../../../resources/runs' import { useDropTipWizardFlows, useTipAttachmentStatus, + DropTipWizardFlows, } from '../../../DropTipWizardFlows' import { useErrorRecoveryFlows, @@ -97,7 +98,9 @@ import { ProtocolDropTipModal, useProtocolDropTipModal, } from '../ProtocolDropTipModal' +import { ConfirmMissingStepsModal } from '../ConfirmMissingStepsModal' +import type { MissingSteps } from '../ProtocolRunSetup' import type { UseQueryResult } from 'react-query' import type { NavigateFunction } from 'react-router-dom' import type { Mock } from 'vitest' @@ -153,6 +156,7 @@ vi.mock('../../../ProtocolUpload/hooks/useMostRecentRunId') vi.mock('../../../../resources/runs') vi.mock('../../../ErrorRecoveryFlows') vi.mock('../ProtocolDropTipModal') +vi.mock('../ConfirmMissingStepsModal') const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -180,6 +184,25 @@ const PROTOCOL_DETAILS = { robotType: 'OT-2 Standard' as const, } +const RUN_COMMAND_ERRORS = { + data: { + data: [ + { + errorCode: '4000', + errorType: 'test', + isDefined: false, + createdAt: '9-9-9', + detail: 'blah blah', + id: '123', + }, + ], + meta: { + cursor: 0, + pageLength: 1, + }, + }, +} as any + const mockMovingHeaterShaker = { id: 'heatershaker_id', moduleModel: 'heaterShakerModuleV1', @@ -215,6 +238,7 @@ const mockDoorStatus = { doorRequiredClosedForProtocol: true, }, } +let mockMissingSteps: MissingSteps = [] const render = () => { return renderWithProviders( @@ -224,6 +248,7 @@ const render = () => { robotName={ROBOT_NAME} runId={RUN_ID} makeHandleJumpToStep={vi.fn(() => vi.fn())} + missingSetupSteps={mockMissingSteps} /> , { i18nInstance: i18n } @@ -240,7 +265,7 @@ describe('ProtocolRunHeader', () => { mockTrackProtocolRunEvent = vi.fn(() => new Promise(resolve => resolve({}))) mockCloseCurrentRun = vi.fn() mockDetermineTipStatus = vi.fn() - + mockMissingSteps = [] vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) vi.mocked(ConfirmCancelModal).mockReturnValue(
Mock ConfirmCancelModal
@@ -267,6 +292,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ConfirmAttachmentModal).mockReturnValue(
mock confirm attachment modal
) + vi.mocked(ConfirmMissingStepsModal).mockReturnValue( +
mock missing steps modal
+ ) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) @@ -340,10 +368,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(), @@ -359,6 +384,7 @@ describe('ProtocolRunHeader', () => { ...noModulesProtocol, ...MOCK_ROTOCOL_LIQUID_KEY, } as any) + vi.mocked(useRunCommandErrors).mockReturnValue(RUN_COMMAND_ERRORS) vi.mocked(useDeckConfigurationCompatibility).mockReturnValue([]) vi.mocked(getIsFixtureMismatch).mockReturnValue(false) vi.mocked(useMostRecentRunId).mockReturnValue(RUN_ID) @@ -384,6 +410,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ProtocolDropTipModal).mockReturnValue(
MOCK_DROP_TIP_MODAL
) + vi.mocked(DropTipWizardFlows).mockReturnValue( +
MOCK_DROP_TIP_WIZARD_FLOWS
+ ) }) afterEach(() => { @@ -472,7 +501,6 @@ describe('ProtocolRunHeader', () => { data: { data: { ...mockIdleUnstartedRun, current: true } }, } as UseQueryResult) render() - expect(mockCloseCurrentRun).toBeCalled() expect(mockTrackProtocolRunEvent).toBeCalled() expect(mockTrackProtocolRunEvent).toBeCalledWith({ name: ANALYTICS_PROTOCOL_RUN_ACTION.FINISH, @@ -844,7 +872,6 @@ describe('ProtocolRunHeader', () => { render() fireEvent.click(screen.queryAllByTestId('Banner_close-button')[0]) - expect(mockCloseCurrentRun).toBeCalled() }) it('does not display the "run successful" banner if the successful run is not current', async () => { @@ -1076,4 +1103,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/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 89238cbaa01..e4fbc00e234 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -40,6 +40,7 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' +import type { MissingSteps } from '../ProtocolRunSetup' import { useNotifyRunQuery } from '../../../../resources/runs' import type * as SharedData from '@opentrons/shared-data' @@ -68,12 +69,18 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } +let mockMissingSteps: MissingSteps = [] +const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { + mockMissingSteps = missingSteps +}) const render = () => { return renderWithProviders( , { i18nInstance: i18n, @@ -83,6 +90,7 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { + mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) @@ -121,7 +129,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(SetupLabware)) .calledWith( expect.objectContaining({ - protocolRunHeaderRef: null, robotName: ROBOT_NAME, runId: RUN_ID, }), @@ -146,6 +153,9 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunPipetteInfoByMount)) .calledWith(RUN_ID) .thenReturn({ left: null, right: null }) + when(vi.mocked(useModuleCalibrationStatus)) + .calledWith(ROBOT_NAME, RUN_ID) + .thenReturn({ complete: true }) }) afterEach(() => { vi.resetAllMocks() @@ -181,13 +191,6 @@ describe('ProtocolRunSetup', () => { screen.getByText('Calibration needed') }) - it('does not render calibration status when run has started', () => { - when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) - render() - expect(screen.queryByText('Calibration needed')).toBeNull() - expect(screen.queryByText('Calibration ready')).toBeNull() - }) - describe('when no modules are in the protocol', () => { it('renders robot calibration setup for OT-2', () => { render() @@ -426,10 +429,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) render() - await new Promise(resolve => setTimeout(resolve, 1000)) - expect(screen.getByText('Mock SetupRobotCalibration')).not.toBeVisible() - expect(screen.getByText('Mock SetupModules')).not.toBeVisible() - expect(screen.getByText('Mock SetupLabware')).not.toBeVisible() screen.getByText('Setup is view-only once run has started') }) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts index 5f6a14090f0..f917f64035f 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts @@ -151,6 +151,7 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, moduleModel: MOCK_MODEL, adapterName: ADAPTER_DISPLAY_NAME, + adapterId: ADAPTER_ID, } expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_MOD_COMMANDS as any) @@ -161,6 +162,7 @@ describe('getLocationInfoNames', () => { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, + adapterId: ADAPTER_ID, } expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts index c01d46259f5..c3404945dcb 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts @@ -9,8 +9,10 @@ import type { export interface LocationInfoNames { slotName: string labwareName: string + labwareNickname?: string adapterName?: string moduleModel?: ModuleModel + adapterId?: string } export function getLocationInfoNames( @@ -39,6 +41,7 @@ export function getLocationInfoNames( loadLabwareCommand.result?.definition != null ? getLabwareDisplayName(loadLabwareCommand.result?.definition) : '' + const labwareNickname = loadLabwareCommand.params.displayName const labwareLocation = loadLabwareCommand.params.location @@ -79,8 +82,10 @@ export function getLocationInfoNames( return { slotName: loadedAdapterCommand?.params.location.slotName, labwareName, + labwareNickname, adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, + adapterId: loadedAdapterCommand?.result?.labwareId, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -96,8 +101,10 @@ export function getLocationInfoNames( ? { slotName: loadModuleCommandUnderAdapter.params.location.slotName, labwareName, + labwareNickname, adapterName: loadedAdapterCommand.result?.definition.metadata.displayName, + adapterId: loadedAdapterCommand?.result?.labwareId, moduleModel: loadModuleCommandUnderAdapter.params.model, } : { slotName: '', labwareName } diff --git a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx index 730677ae842..883abd15a28 100644 --- a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx +++ b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx @@ -9,7 +9,9 @@ import { storedProtocolData as storedProtocolDataFixture } from '../../../redux/ import { useRunStatus, useRunTimestamps } from '../../RunTimeControl/hooks' import { HistoricalProtocolRun } from '../HistoricalProtocolRun' import { HistoricalProtocolRunOverflowMenu } from '../HistoricalProtocolRunOverflowMenu' + import type { RunStatus, RunData } from '@opentrons/api-client' +import type { RunTimeParameter } from '@opentrons/shared-data' vi.mock('../../../redux/protocol-storage') vi.mock('../../RunTimeControl/hooks') @@ -20,6 +22,7 @@ const run = { id: 'test_id', protocolId: 'test_protocol_id', status: 'succeeded' as RunStatus, + runTimeParameters: [] as RunTimeParameter[], } as RunData const render = (props: React.ComponentProps) => { diff --git a/app/src/organisms/DropTipWizardFlows/Success.tsx b/app/src/organisms/DropTipWizardFlows/Success.tsx index 7fb10ae9cc4..5e72dd66281 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 980367fe2e0..2789d5892e1 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 { OddModalHeaderBaseProps } from '../../molecules/OddModal/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: OddModalHeaderBaseProps = { @@ -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 d0763e3e307..bd1cc918ea5 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 135ff4e0e6e..edd24d50e10 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(