From a2b5c44000fd09e91e64bdf143e69640bb751b3c Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Fri, 2 Aug 2024 15:23:58 -0400 Subject: [PATCH 01/39] fix(protocol-engine): Update location after `liquidProbe` and `tryLiquidProbe` (#15867) --- .../protocol_engine/state/pipettes.py | 154 +++++++++--------- .../state/test_pipette_store.py | 91 +++++++++++ 2 files changed, 169 insertions(+), 76 deletions(-) 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/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( From 47f15dc7306438853510fff3e8183a25b8895c08 Mon Sep 17 00:00:00 2001 From: syao1226 <146495172+syao1226@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:15:02 -0400 Subject: [PATCH 02/39] feat(robot_server): send and save csv rtp files for runs (#15857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit re AUTH-591 # Overview Send and save the CSV RTPs used in protocol runs to the new table created in the database, and hook it up to the GET endpoint that fetches all the data files used in analysis and run. ## Test Plan and Hands on Testing - run test files to ensure that csv rtps get insert to the new run_csv_rtp_table and able to get them from the table ## Changelog - Added `run_csv_rtp_table` to schema_6.py file - Added runTimeParameterFiles field to the run creation request model - Passed rtp files input onto the Run Orchestrator’s load process - Added `insert_csv_rtp()` and `get_all_csv_rtp()` function - Updated `get_reference_data_files()` to add file referenced in runs - Updated tests --------- Co-authored-by: shiyaochen Co-authored-by: Sanniti --- .../robot_server/protocols/protocol_store.py | 31 ++++- .../robot_server/runs/router/base_router.py | 4 + .../robot_server/runs/run_data_manager.py | 10 +- robot-server/robot_server/runs/run_models.py | 5 + .../runs/run_orchestrator_store.py | 11 +- robot-server/robot_server/runs/run_store.py | 59 ++++++++ ...t_run_with_run_time_parameters.tavern.yaml | 131 ++++++------------ .../tests/protocols/test_protocol_store.py | 62 +++++++-- .../tests/runs/router/test_base_router.py | 7 +- .../tests/runs/test_run_data_manager.py | 23 +++ robot-server/tests/runs/test_run_store.py | 52 ++++++- 11 files changed, 285 insertions(+), 110 deletions(-) diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index 0488a958a12..13676a798eb 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -24,6 +24,7 @@ analysis_primitive_type_rtp_table, analysis_csv_rtp_table, data_files_table, + run_csv_rtp_table, ProtocolKindSQLEnum, ) from robot_server.protocols.protocol_models import ProtocolKind @@ -310,20 +311,38 @@ def get_usage_info(self) -> List[ProtocolUsageInfo]: # TODO (spp, 2024-07-22): get files referenced in runs as well async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: """Get a list of data files referenced in specified protocol's analyses and runs.""" - # Get analyses of protocol_id + # Get analyses and runs of protocol_id select_referencing_analysis_ids = sqlalchemy.select(analysis_table.c.id).where( analysis_table.c.protocol_id == protocol_id ) + select_referencing_run_ids = sqlalchemy.select(run_table.c.id).where( + run_table.c.protocol_id == protocol_id + ) # Get all entries in csv table that match the analyses - csv_file_ids = sqlalchemy.select(analysis_csv_rtp_table.c.file_id).where( + analysis_csv_file_ids = sqlalchemy.select( + analysis_csv_rtp_table.c.file_id + ).where( analysis_csv_rtp_table.c.analysis_id.in_(select_referencing_analysis_ids) ) + run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( + run_csv_rtp_table.c.run_id.in_(select_referencing_run_ids) + ) # Get list of data file IDs from the entries - select_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(csv_file_ids) + select_analysis_data_file_rows_statement = data_files_table.select().where( + data_files_table.c.id.in_(analysis_csv_file_ids) + ) + select_run_data_file_rows_statement = data_files_table.select().where( + data_files_table.c.id.in_(run_csv_file_ids) ) with self._sql_engine.begin() as transaction: - data_files_rows = transaction.execute(select_data_file_rows_statement).all() + analysis_data_files_rows = transaction.execute( + select_analysis_data_file_rows_statement + ).all() + run_data_files_rows = transaction.execute( + select_run_data_file_rows_statement + ).all() + + combine_data_file_rows = set(analysis_data_files_rows + run_data_files_rows) return [ DataFile( @@ -331,7 +350,7 @@ async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: name=sql_row.name, createdAt=sql_row.created_at, ) - for sql_row in data_files_rows + for sql_row in combine_data_file_rows ] def get_referencing_run_ids(self, protocol_id: str) -> List[str]: diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 14c5b822fda..1ed03b44cd7 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -176,6 +176,9 @@ async def create_run( rtp_values = ( request_body.data.runTimeParameterValues if request_body is not None else None ) + rtp_files = ( + request_body.data.runTimeParameterFiles if request_body is not None else None + ) protocol_resource = None deck_configuration = await deck_configuration_store.get_deck_configuration() @@ -206,6 +209,7 @@ async def create_run( labware_offsets=offsets, deck_configuration=deck_configuration, run_time_param_values=rtp_values, + run_time_param_files=rtp_files, protocol=protocol_resource, notify_publishers=notify_publishers, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index de5eea82e45..c5cacbb7571 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -12,7 +12,10 @@ CommandPointer, Command, ) -from opentrons.protocol_engine.types import PrimitiveRunTimeParamValuesType +from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, + PrimitiveRunTimeParamValuesType, +) from robot_server.protocols.protocol_store import ProtocolResource from robot_server.service.task_runner import TaskRunner @@ -156,6 +159,7 @@ async def create( labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], + run_time_param_files: Optional[CSVRunTimeParamFilesType], notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], ) -> Union[Run, BadRun]: @@ -168,6 +172,7 @@ async def create( deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. notify_publishers: Utilized by the engine to notify publishers of state changes. run_time_param_values: Any runtime parameter values to set. + run_time_param_files: Any runtime parameter values to set. protocol: The protocol to load the runner with, if any. Returns: @@ -192,6 +197,7 @@ async def create( deck_configuration=deck_configuration, protocol=protocol, run_time_param_values=run_time_param_values, + run_time_param_files=run_time_param_files, notify_publishers=notify_publishers, ) run_resource = self._run_store.insert( @@ -210,7 +216,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, - run_time_parameters=[], + run_time_parameters=self._run_orchestrator_store.get_run_time_parameters(), ) def get(self, run_id: str) -> Union[Run, BadRun]: diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 45ad22e3167..db068870915 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -19,6 +19,7 @@ CommandNote, ) from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, RunTimeParameter, PrimitiveRunTimeParamValuesType, ) @@ -252,6 +253,10 @@ class RunCreate(BaseModel): None, description="Key-value pairs of run-time parameters defined in a protocol.", ) + runTimeParameterFiles: Optional[CSVRunTimeParamFilesType] = Field( + None, + description="Key-fileId pairs of CSV run-time parameters defined in a protocol.", + ) class RunUpdate(BaseModel): diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 11448a81d0c..953c9758cb1 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -4,7 +4,11 @@ from typing import List, Optional, Callable from opentrons.protocol_engine.errors.exceptions import EStopActivatedError -from opentrons.protocol_engine.types import PostRunHardwareState, RunTimeParameter +from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, + PostRunHardwareState, + RunTimeParameter, +) from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.robot.types import RobotType @@ -188,6 +192,7 @@ async def create( notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, + run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. @@ -198,6 +203,7 @@ async def create( notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. run_time_param_values: Any runtime parameter values to set. + run_time_param_files: Any runtime parameter files to set. Returns: The initial equipment and status summary of the engine. @@ -243,8 +249,7 @@ async def create( await self.run_orchestrator.load( protocol.source, run_time_param_values=run_time_param_values, - # TODO (spp, 2024-07-16): update this once runs accept csv params - run_time_param_files={}, + run_time_param_files=run_time_param_files, parse_mode=ParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) else: diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 6cf86d14af1..bbd50b1f713 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -25,6 +25,7 @@ run_table, run_command_table, action_table, + run_csv_rtp_table, ) from robot_server.persistence.pydantic import ( json_to_pydantic, @@ -85,6 +86,15 @@ class BadStateSummary: dataError: EnumeratedError +@dataclass +class CSVParameterRunResource: + """A CSV runtime parameter from a completed run, storable in a SQL database.""" + + run_id: str + parameter_variable_name: str + file_id: Optional[str] + + class CommandNotFoundError(ValueError): """Error raised when a given command ID is not found in the store.""" @@ -198,6 +208,39 @@ def insert_action(self, run_id: str, action: RunAction) -> None: self._clear_caches() + def get_all_csv_rtp(self) -> List[CSVParameterRunResource]: + """Get all of the csv rtp from the run_csv_rtp_table.""" + select_all_csv_rtp = sqlalchemy.select(run_csv_rtp_table).order_by( + sqlite_rowid.asc() + ) + + with self._sql_engine.begin() as transaction: + csv_rtps = transaction.execute(select_all_csv_rtp).all() + + return [_covert_row_to_csv_rtp(row) for row in csv_rtps] + + def insert_csv_rtp( + self, run_id: str, run_time_parameters: List[RunTimeParameter] + ) -> None: + """Save csv rtp to the run_csv_rtp_table.""" + insert_csv_rtp = sqlalchemy.insert(run_csv_rtp_table) + + with self._sql_engine.begin() as transaction: + if not self._run_exists(run_id, transaction): + raise RunNotFoundError(run_id=run_id) + for run_time_param in run_time_parameters: + if run_time_param.type == "csv_file": + transaction.execute( + insert_csv_rtp, + { + "run_id": run_id, + "parameter_variable_name": run_time_param.variableName, + "file_id": run_time_param.file.id + if run_time_param.file + else None, + }, + ) + def insert( self, run_id: str, @@ -531,6 +574,22 @@ def _clear_caches(self) -> None: _run_columns = [run_table.c.id, run_table.c.protocol_id, run_table.c.created_at] +def _covert_row_to_csv_rtp( + row: sqlalchemy.engine.Row, +) -> CSVParameterRunResource: + run_id = row.run_id + parameter_variable_name = row.parameter_variable_name + file_id = row.file_id + + assert isinstance(run_id, str) + assert isinstance(parameter_variable_name, str) + assert isinstance(file_id, str) or file_id is None + + return CSVParameterRunResource( + run_id=run_id, parameter_variable_name=parameter_variable_name, file_id=file_id + ) + + def _convert_row_to_run( row: sqlalchemy.engine.Row, action_rows: List[sqlalchemy.engine.Row], diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index 9d91abea32f..a616a50cc66 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -34,6 +34,7 @@ stages: save: json: run_id: data.id + run_time_parameters_data: data.runTimeParameters json: data: id: !anystr @@ -48,7 +49,49 @@ stages: modules: [] labware: [] labwareOffsets: [] - runTimeParameters: [] + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file liquids: [] protocolId: '{protocol_id}' @@ -96,48 +139,7 @@ stages: createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" status: succeeded current: True - runTimeParameters: - - displayName: Sample count - variableName: sample_count - type: int - default: 6.0 - min: 1.0 - max: 12.0 - value: 4.0 - description: How many samples to process. - - displayName: Pipette volume - variableName: volume - type: float - default: 20.1 - choices: - - displayName: Low Volume - value: 10.23 - - displayName: Medium Volume - value: 20.1 - - displayName: High Volume - value: 50.5 - value: 10.23 - description: How many microliters to pipette of each sample. - - displayName: Dry Run - variableName: dry_run - type: bool - default: false - value: true - description: Skip aspirate and dispense steps. - - displayName: Pipette Name - variableName: pipette - type: str - choices: - - displayName: Single channel 50µL - value: flex_1channel_50 - - displayName: Eight Channel 50µL - value: flex_8channel_50 - default: flex_1channel_50 - value: flex_8channel_50 - description: What pipette to use during the protocol. - - displayName: Liquid handling CSV file - variableName: liq_handling_csv_file - description: A CSV file that contains wells to use for pipetting + runTimeParameters: !force_original_structure '{run_time_parameters_data}' protocolId: '{protocol_id}' - name: Mark the run as not-current @@ -165,46 +167,5 @@ stages: createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" status: succeeded current: False - runTimeParameters: - - displayName: Sample count - variableName: sample_count - type: int - default: 6.0 - min: 1.0 - max: 12.0 - value: 4.0 - description: How many samples to process. - - displayName: Pipette volume - variableName: volume - type: float - default: 20.1 - choices: - - displayName: Low Volume - value: 10.23 - - displayName: Medium Volume - value: 20.1 - - displayName: High Volume - value: 50.5 - value: 10.23 - description: How many microliters to pipette of each sample. - - displayName: Dry Run - variableName: dry_run - type: bool - default: false - value: true - description: Skip aspirate and dispense steps. - - displayName: Pipette Name - variableName: pipette - type: str - choices: - - displayName: Single channel 50µL - value: flex_1channel_50 - - displayName: Eight Channel 50µL - value: flex_8channel_50 - default: flex_1channel_50 - value: flex_8channel_50 - description: What pipette to use during the protocol. - - displayName: Liquid handling CSV file - variableName: liq_handling_csv_file - description: A CSV file that contains wells to use for pipetting + runTimeParameters: !force_original_structure '{run_time_parameters_data}' protocolId: '{protocol_id}' diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 952bcb9c0fd..ff6d4ce7b49 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -1,4 +1,5 @@ """Tests for the ProtocolStore interface.""" +from opentrons.protocol_engine.types import CSVParameter, FileInfo import pytest from decoy import Decoy from datetime import datetime, timezone @@ -530,6 +531,7 @@ async def test_get_referenced_data_files( subject: ProtocolStore, data_files_store: DataFilesStore, completed_analysis_store: CompletedAnalysisStore, + run_store: RunStore, ) -> None: """It should fetch a list of data files referenced in protocol's analyses and runs.""" protocol_resource_1 = ProtocolResource( @@ -579,6 +581,7 @@ async def test_get_referenced_data_files( liquids=[], ), ) + subject.insert(protocol_resource_1) await data_files_store.insert( DataFileInfo( @@ -596,6 +599,32 @@ async def test_get_referenced_data_files( created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) ) + await data_files_store.insert( + DataFileInfo( + id="data-file-id-3", + name="file-name", + file_hash="abc123", + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + ) + + run_store.insert( + run_id="run-id-1", + protocol_id="protocol-id", + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + + run_store.insert_csv_rtp( + run_id="run-id-1", + run_time_parameters=[ + CSVParameter( + variableName="csvFile", + displayName="csv param", + file=FileInfo(id="data-file-id-3", name="file-name"), + ) + ], + ) + await completed_analysis_store.make_room_and_add( completed_analysis_resource=analysis_resource1, primitive_rtp_resources=[], @@ -618,15 +647,24 @@ async def test_get_referenced_data_files( csv_rtp_resources=[], ) result = await subject.get_referenced_data_files("protocol-id") - assert result == [ - DataFile( - id="data-file-id", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-2", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - ] + + for data_file in result: + assert data_file in [ + DataFile( + id="data-file-id", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-2", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-3", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + ] + + assert len(result) == 3 diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 979d3a92371..fd1cdd8b58a 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -97,6 +97,7 @@ async def test_create_run( deck_configuration=[], protocol=None, run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -177,6 +178,7 @@ async def test_create_protocol_run( deck_configuration=[], protocol=protocol_resource, run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -184,7 +186,9 @@ async def test_create_protocol_run( result = await create_run( request_body=RequestModel( data=RunCreate( - protocolId="protocol-id", runTimeParameterValues={"foo": "bar"} + protocolId="protocol-id", + runTimeParameterValues={"foo": "bar"}, + runTimeParameterFiles={"my_file": "file-id"}, ) ), protocol_store=mock_protocol_store, @@ -245,6 +249,7 @@ async def test_create_run_conflict( deck_configuration=[], protocol=None, run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index ba0b457f9f6..a369f7f47b0 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -19,6 +19,7 @@ ) from opentrons.protocol_engine import Liquid from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy +from opentrons.protocol_engine.types import BooleanParameter, CSVParameter from opentrons.protocol_runner import RunResult from opentrons.types import DeckSlotName @@ -167,9 +168,13 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) + + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) + decoy.when( mock_run_store.insert( run_id=run_id, @@ -185,6 +190,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) @@ -238,6 +244,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -250,6 +257,16 @@ async def test_create_with_options( ) ).then_return(run_resource) + bool_parameter = BooleanParameter( + displayName="foo", variableName="bar", default=True, value=False + ) + + file_parameter = CSVParameter(displayName="my_file", variableName="file-id") + + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return( + [bool_parameter, file_parameter] + ) + result = await subject.create( run_id=run_id, created_at=created_at, @@ -257,6 +274,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) @@ -274,6 +292,7 @@ async def test_create_with_options( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=[bool_parameter, file_parameter], ) @@ -294,6 +313,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) @@ -306,6 +326,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) @@ -753,6 +774,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -772,6 +794,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 7e4155ef1b5..f4b2b8e154f 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -1,9 +1,11 @@ """Tests for robot_server.runs.run_store.""" from datetime import datetime, timezone +from pathlib import Path from typing import List, Optional, Type import pytest from decoy import Decoy +from robot_server.data_files.data_files_store import DataFileInfo, DataFilesStore from sqlalchemy.engine import Engine from unittest import mock @@ -12,6 +14,7 @@ from robot_server.protocols.protocol_store import ProtocolNotFoundError from robot_server.runs.run_store import ( + CSVParameterRunResource, RunStore, RunResource, CommandNotFoundError, @@ -157,6 +160,7 @@ def run_time_parameters() -> List[pe_types.RunTimeParameter]: displayName="Display Name 4", variableName="variable_name_4", description="a csv parameter without file id", + file=pe_types.FileInfo(id="file-id", name="csvFile"), ), ] @@ -202,7 +206,20 @@ def invalid_state_summary() -> StateSummary: ) -def test_update_run_state( +@pytest.fixture +def data_files_store(sql_engine: Engine, tmp_path: Path) -> DataFilesStore: + """Return a `DataFilesStore` linked to the same database as the subject under test. + + `DataFilesStore` is tested elsewhere. + We only need it here to prepare the database for the analysis store tests. + The CSV parameters table always needs a data file to link to. + """ + data_files_dir = tmp_path / "data_files" + data_files_dir.mkdir() + return DataFilesStore(sql_engine=sql_engine, data_files_directory=data_files_dir) + + +async def test_update_run_state( subject: RunStore, state_summary: StateSummary, protocol_commands: List[pe_commands.Command], @@ -252,6 +269,39 @@ def test_update_run_state( ) +async def test_insert_and_get_csv_rtp( + subject: RunStore, + data_files_store: DataFilesStore, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: + """It should be able to insert and get csv rtp from the db.""" + await data_files_store.insert( + DataFileInfo( + id="file-id", + name="my_csv_file.csv", + file_hash="file-hash", + created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), + ) + ) + + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + + subject.insert_csv_rtp(run_id="run-id", run_time_parameters=run_time_parameters) + csv_rtp_result = subject.get_all_csv_rtp() + + assert csv_rtp_result == [ + CSVParameterRunResource( + run_id="run-id", + parameter_variable_name="variable_name_4", + file_id="file-id", + ) + ] + + def test_update_state_run_not_found( subject: RunStore, state_summary: StateSummary, From f2f3c74b6a69774349286033fb85a71644411a1d Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Fri, 2 Aug 2024 18:20:58 -0400 Subject: [PATCH 03/39] feat(api, robot-server): use runtime parameter files set in protocols and runs to set in-protocol values (#15855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview Closes AUTH-419. This PR hooks up the setting of runtime parameter files in the POST `/runs`, `/protocols` and `/protocols/{protocolId}/analyses` to the actual `CSVParameter` object used in the protocol context. This file is sent via a dictionary of parameter name and file-id, so we resolve the file-id into a `Path` by using the `data_files_directory` and `data_files_store`. This is then passed to the `ParameterContext` and used to open up a temporary file that contains the contents of the actual file stored on the robot. ## Test Plan and Hands on Testing Tested end to end via the following protocol and two CSV files ``` metadata = { 'protocolName': 'CSV End to End Test', } requirements = { "robotType": "Flex", "apiLevel": "2.18" } def add_parameters(parameters): parameters.add_str( display_name="Pipette Name", variable_name="pipette", choices=[ {"display_name": "Single channel 50µL", "value": "flex_1channel_50"}, {"display_name": "Single channel 1000µL", "value": "flex_1channel_1000"}, {"display_name": "Eight Channel 50µL", "value": "flex_8channel_50"}, {"display_name": "Eight Channel 1000µL", "value": "flex_8channel_1000"}, ], default="flex_1channel_50", description="What pipette to use during the protocol.", ) parameters.add_csv_file( display_name="CSV Data", variable_name="csv_data", description="CSV file containing labware and volume information." ) def run(context): PIPETTE_NAME = context.params.pipette trash_bin = context.load_trash_bin('A3') tip_rack = context.load_labware('opentrons_flex_96_tiprack_50ul', 'D2') pipette = context.load_instrument(PIPETTE_NAME, mount="left", tip_racks=[tip_rack]) csv_file_data = context.params.csv_data.parse_as_csv() labware_name = csv_file_data[1][0].strip() location = csv_file_data[1][1].strip() volume = float(csv_file_data[1][2]) labware = context.load_labware(labware_name, location) pipette.pick_up_tip() pipette.aspirate(volume, labware.wells()[0].top()) pipette.dispense(volume, labware.wells()[1].top()) pipette.drop_tip() ``` ``` Labware Name, Location, Volume opentrons_96_wellplate_200ul_pcr_full_skirt, C1, 20 ``` ``` Labware Name, Location, Volume nest_96_wellplate_100ul_pcr_full_skirt, C2, 30 ``` ## Changelog - Resolve `runTimeParameterFiles` into a dictionary of `Paths` - pass the `run_time_param_paths` all the way to the `ParameterContext` where they can be opened as temporary file handlers - close the file handlers upon protocol end - Allow importing of `CSVParameter` from the `protocol_api` namespace. ## Review requests ## Risk assessment Medium. --------- Co-authored-by: Sanniti --- api/src/opentrons/cli/analyze.py | 2 +- api/src/opentrons/protocol_api/__init__.py | 2 + .../protocol_api/_parameter_context.py | 46 ++++++++++-- api/src/opentrons/protocol_engine/types.py | 2 + .../protocol_runner/protocol_runner.py | 11 +-- .../python_protocol_wrappers.py | 11 ++- .../protocol_runner/run_orchestrator.py | 6 +- .../opentrons/protocols/execution/execute.py | 29 +++++--- .../protocols/execution/execute_python.py | 6 +- .../opentrons/protocols/parameters/types.py | 1 + .../protocol_runner/test_protocol_runner.py | 8 +- .../protocol_runner/test_run_orchestrator.py | 8 +- .../protocols/analyses_manager.py | 6 +- .../protocols/protocol_analyzer.py | 6 +- robot-server/robot_server/protocols/router.py | 36 +++++++-- .../robot_server/runs/router/base_router.py | 22 +++++- .../robot_server/runs/run_data_manager.py | 8 +- robot-server/robot_server/runs/run_models.py | 4 +- .../runs/run_orchestrator_store.py | 9 ++- robot-server/settings_schema.json | 40 ++++++++-- .../integration/data_files/sample_plates.csv | 41 ++++++++++ ...lyses_with_csv_file_parameters.tavern.yaml | 2 +- ...t_run_with_run_time_parameters.tavern.yaml | 22 ++++++ .../tests/protocols/test_analyses_manager.py | 8 +- .../tests/protocols/test_protocol_analyzer.py | 8 +- .../tests/protocols/test_protocols_router.py | 74 +++++++++++++++---- .../tests/runs/router/test_base_router.py | 30 +++++++- .../tests/runs/test_run_data_manager.py | 22 +++--- 28 files changed, 363 insertions(+), 107 deletions(-) create mode 100644 robot-server/tests/integration/data_files/sample_plates.csv 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/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 975f2996c98..ed95efca22d 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -12,6 +12,7 @@ from opentrons.protocols.parameters.exceptions import ( RuntimeParameterRequired as RuntimeParameterRequiredError, ) +from opentrons.protocols.parameters.types import CSVParameter from .protocol_context import ProtocolContext from .deck import Deck @@ -74,6 +75,7 @@ "ALL", "OFF_DECK", "RuntimeParameterRequiredError", + "CSVParameter", # For internal Opentrons use only: "create_protocol_context", "ProtocolEngineCoreRequiredError", diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index 32528dab8d6..f334c2ef1d2 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -1,5 +1,5 @@ """Parameter context for python protocols.""" - +import tempfile from typing import List, Optional, Union, Dict from opentrons.protocols.api_support.types import APIVersion @@ -19,7 +19,7 @@ from opentrons.protocol_engine.types import ( RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) @@ -218,7 +218,7 @@ def set_parameters( parameter.value = validated_value def initialize_csv_files( - self, run_time_param_file_overrides: CSVRunTimeParamFilesType + self, run_time_param_file_overrides: CSVRuntimeParamPaths ) -> None: """Initializes the files for CSV parameters. @@ -226,7 +226,7 @@ def initialize_csv_files( This is intended for Opentrons internal use only and is not a guaranteed API. """ - for variable_name, file_id in run_time_param_file_overrides.items(): + for variable_name, file_path in run_time_param_file_overrides.items(): try: parameter = self._parameters[variable_name] except KeyError: @@ -240,11 +240,41 @@ def initialize_csv_files( f"File Id was provided for the parameter '{variable_name}'," f" but '{variable_name}' is not a CSV parameter." ) + # TODO(jbl 2024-08-02) This file opening should be moved elsewhere to provide more flexibility with files + # that may be opened as non-text or non-UTF-8 + # The parent folder in the path will be the file ID, so we can use that to resolve that here + file_id = file_path.parent.name + file_name = file_path.name + + # Read the contents of the actual file + with file_path.open() as csv_file: + contents = csv_file.read() + + # Open a temporary file with write permissions and write contents to that + temporary_file = tempfile.NamedTemporaryFile("r+") + temporary_file.write(contents) + temporary_file.flush() + + # Open a new file handler for the temporary file with read-only permissions and close the other + parameter_file = open(temporary_file.name, "r") + temporary_file.close() + + parameter.file_info = FileInfo(id=file_id, name=file_name) + parameter.value = parameter_file + + def close_csv_files(self) -> None: + """Close all file handlers for CSV parameters. - parameter.file_info = FileInfo(id=file_id, name="") - # TODO (spp, 2024-07-16): set the file name and assign the file as parameter.value. - # Most likely, we will be creating a temporary file copy of the original - # to pass onto the protocol context + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + for parameter in self._parameters.values(): + if ( + isinstance(parameter, csv_parameter_definition.CSVParameterDefinition) + and parameter.value is not None + ): + parameter.value.close() def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. diff --git a/api/src/opentrons/protocol_engine/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/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index bfe959ca0eb..b0aabad34a4 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -44,7 +44,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocols.types import PythonProtocol @@ -186,7 +186,7 @@ async def load( protocol_source: ProtocolSource, python_parse_mode: PythonParseMode, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> None: """Load a Python or JSONv5(& older) ProtocolSource into managed ProtocolEngine.""" labware_definitions = await protocol_reader.extract_labware_definitions( @@ -209,7 +209,7 @@ async def load( protocol=protocol, parameter_context=self._parameter_context, run_time_param_overrides=run_time_param_values, - run_time_param_file_overrides=run_time_param_files, + run_time_param_file_overrides=run_time_param_paths, ) ) else: @@ -244,6 +244,7 @@ async def run_func() -> None: await self._protocol_executor.execute( protocol=protocol, context=context, + parameter_context=self._parameter_context, run_time_parameters_with_overrides=run_time_parameters_with_overrides, ) @@ -254,7 +255,7 @@ async def run( # noqa: D102 deck_configuration: DeckConfigurationType, protocol_source: Optional[ProtocolSource] = None, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, - run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, + run_time_param_paths: Optional[CSVRuntimeParamPaths] = None, python_parse_mode: PythonParseMode = PythonParseMode.NORMAL, ) -> RunResult: # TODO(mc, 2022-01-11): move load to runner creation, remove from `run` @@ -264,7 +265,7 @@ async def run( # noqa: D102 protocol_source=protocol_source, python_parse_mode=python_parse_mode, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) self.play(deck_configuration=deck_configuration) diff --git a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py index e1090d98fa4..17f82b88846 100644 --- a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py +++ b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py @@ -15,7 +15,7 @@ from opentrons.protocol_engine import ProtocolEngine from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons.protocol_reader import ProtocolSource, ProtocolFileRole from opentrons.util.broker import Broker @@ -153,11 +153,16 @@ class PythonProtocolExecutor: async def execute( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext], run_time_parameters_with_overrides: Optional[Parameters], ) -> None: """Execute a PAPIv2 protocol with a given ProtocolContext in a child thread.""" await to_thread.run_sync( - run_protocol, protocol, context, run_time_parameters_with_overrides + run_protocol, + protocol, + context, + parameter_context, + run_time_parameters_with_overrides, ) @staticmethod @@ -165,7 +170,7 @@ def extract_run_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Extract the parameters defined in the protocol, overridden with values for the run.""" return exec_add_parameters( diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index fe5cf4483f6..7c748598f21 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -32,7 +32,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocol_engine.error_recovery_policy import ErrorRecoveryPolicy @@ -340,7 +340,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 +356,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..46f429839f5 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -1,7 +1,7 @@ import logging from typing import Optional -from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api import ProtocolContext, ParameterContext from opentrons.protocol_api._parameters import Parameters from opentrons.protocols.execution.execute_python import exec_run from opentrons.protocols.execution.json_dispatchers import ( @@ -21,25 +21,36 @@ def run_protocol( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext] = None, run_time_parameters_with_overrides: Optional[Parameters] = None, ) -> None: """Run a protocol. :param protocol: The :py:class:`.protocols.types.Protocol` to execute :param context: The protocol context to use. + :param parameter_context: The parameter context to use if running with runtime parameters. :param run_time_parameters_with_overrides: Run time parameters defined in the protocol, updated with the run's RTP override values. When we are running either simulate or execute, this will be None (until RTP is supported in cli commands) """ if isinstance(protocol, PythonProtocol): - if protocol.api_level >= APIVersion(2, 0): - exec_run( - proto=protocol, - context=context, - run_time_parameters_with_overrides=run_time_parameters_with_overrides, - ) - else: - raise RuntimeError(f"Unsupported python API version: {protocol.api_level}") + try: + if protocol.api_level >= APIVersion(2, 0): + exec_run( + proto=protocol, + context=context, + run_time_parameters_with_overrides=run_time_parameters_with_overrides, + ) + else: + raise RuntimeError( + f"Unsupported python API version: {protocol.api_level}" + ) + except Exception: + raise + finally: + # TODO(jbl 2024-08-02) this should be more tightly bound to the opening of the csv files + if parameter_context is not None: + parameter_context.close_csv_files() else: if protocol.contents["schemaVersion"] == 3: ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents) diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 59c9db943df..5d7793144d3 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -12,7 +12,7 @@ from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) @@ -71,7 +71,7 @@ def _raise_pretty_protocol_error(exception: Exception, filename: str) -> None: def _parse_and_set_parameters( parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], new_globs: Dict[Any, Any], filename: str, ) -> Parameters: @@ -111,7 +111,7 @@ def exec_add_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Exec the add_parameters function and get the final run time parameters with overrides.""" new_globs: Dict[Any, Any] = {} diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py index 46b47a04282..a4b4e30e5c6 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -4,6 +4,7 @@ from .exceptions import RuntimeParameterRequired, ParameterValueError +# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder class CSVParameter: def __init__(self, csv_file: Optional[TextIO]) -> None: self._file = csv_file diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index e975e90fa73..14307411d53 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -645,7 +645,7 @@ async def test_load_legacy_python( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -668,6 +668,7 @@ async def test_load_legacy_python( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=python_runner_subject._parameter_context, run_time_parameters_with_overrides=None, ), ) @@ -727,7 +728,7 @@ async def test_load_python_with_pe_papi_core( protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) decoy.verify(protocol_engine.add_plugin(matchers.IsA(LegacyContextPlugin)), times=0) @@ -790,7 +791,7 @@ async def test_load_legacy_json( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -811,6 +812,7 @@ async def test_load_legacy_json( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=None, run_time_parameters_with_overrides=None, ), ) diff --git a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py index 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/robot-server/robot_server/protocols/analyses_manager.py b/robot-server/robot_server/protocols/analyses_manager.py index 4485dce3b60..3a41568371f 100644 --- a/robot-server/robot_server/protocols/analyses_manager.py +++ b/robot-server/robot_server/protocols/analyses_manager.py @@ -5,7 +5,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons.protocol_engine.errors import ErrorOccurrence @@ -40,7 +40,7 @@ async def initialize_analyzer( analysis_id: str, protocol_resource: ProtocolResource, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> protocol_analyzer.ProtocolAnalyzer: """Initialize the protocol analyzer with protocol resource and run time parameter values & fileIds. @@ -61,7 +61,7 @@ async def initialize_analyzer( try: await analyzer.load_orchestrator( run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) except Exception as error: internal_error = em.map_unexpected_error(error) diff --git a/robot-server/robot_server/protocols/protocol_analyzer.py b/robot-server/robot_server/protocols/protocol_analyzer.py index 5ab628e42b0..65cb2adf7c8 100644 --- a/robot-server/robot_server/protocols/protocol_analyzer.py +++ b/robot-server/robot_server/protocols/protocol_analyzer.py @@ -10,7 +10,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, RunTimeParameter, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) import opentrons.util.helpers as datetime_helper from opentrons.protocol_runner import ( @@ -53,7 +53,7 @@ def get_verified_run_time_parameters(self) -> List[RunTimeParameter]: async def load_orchestrator( self, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> None: """Load runner with the protocol and run time parameter values. @@ -67,7 +67,7 @@ async def load_orchestrator( protocol_source=self._protocol_resource.source, parse_mode=ParseMode.NORMAL, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) @TrackingFunctions.track_analysis diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 1e50b6a13a9..5380464bae4 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -9,7 +9,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons_shared_data.robot import user_facing_robot_type from opentrons.util.performance_helpers import TrackingFunctions @@ -48,6 +48,11 @@ PydanticResponse, RequestModel, ) +from robot_server.data_files.dependencies import ( + get_data_files_directory, + get_data_files_store, +) +from robot_server.data_files.data_files_store import DataFilesStore from robot_server.data_files.models import DataFile from .analyses_manager import AnalysesManager, FailedToInitializeAnalyzer @@ -245,6 +250,8 @@ async def create_protocol( # noqa: C901 quick_transfer_protocol_auto_deleter: ProtocolAutoDeleter = Depends( get_quick_transfer_protocol_auto_deleter ), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), robot_type: RobotType = Depends(get_robot_type), protocol_id: str = Depends(get_unique_id, use_cache=False), analysis_id: str = Depends(get_unique_id, use_cache=False), @@ -272,6 +279,8 @@ async def create_protocol( # noqa: C901 the new protocol. quick_transfer_protocol_auto_deleter: An interface to delete old quick transfer resources to make room for the new protocol. + data_files_directory: Persistence directory for data files. + data_files_store: Database of data file resources. robot_type: The type of this robot. Protocols meant for other robot types are rejected. protocol_id: Unique identifier to attach to the protocol resource. @@ -314,6 +323,11 @@ async def create_protocol( # noqa: C901 assert file.filename is not None buffered_files = await file_reader_writer.read(files=files) # type: ignore[arg-type] + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in parsed_rtp_files.items() + } + content_hash = await file_hasher.hash(buffered_files) cached_protocol_id = protocol_store.get_id_by_hash(content_hash) @@ -330,7 +344,7 @@ async def _get_cached_protocol_analysis() -> PydanticResponse[ analysis_id=analysis_id, force_analyze=False, rtp_values=parsed_rtp_values, - rtp_files=parsed_rtp_files, + rtp_files=rtp_paths, protocol_resource=protocol_store.get( protocol_id=cached_protocol_id ), @@ -409,7 +423,7 @@ async def _get_cached_protocol_analysis() -> PydanticResponse[ analysis_id=analysis_id, force_analyze=True, rtp_values=parsed_rtp_values, - rtp_files=parsed_rtp_files, + rtp_files=rtp_paths, protocol_resource=protocol_resource, analysis_store=analysis_store, analyses_manager=analyses_manager, @@ -440,7 +454,7 @@ async def _start_new_analysis_if_necessary( analysis_id: str, force_analyze: bool, rtp_values: PrimitiveRunTimeParamValuesType, - rtp_files: CSVRunTimeParamFilesType, + rtp_files: CSVRuntimeParamPaths, protocol_resource: ProtocolResource, analysis_store: AnalysisStore, analyses_manager: AnalysesManager, @@ -458,7 +472,7 @@ async def _start_new_analysis_if_necessary( analysis_id=analysis_id, protocol_resource=protocol_resource, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_files, ) except FailedToInitializeAnalyzer: analyses.append( @@ -695,6 +709,8 @@ async def create_protocol_analysis( analysis_store: AnalysisStore = Depends(get_analysis_store), analyses_manager: AnalysesManager = Depends(get_analyses_manager), analysis_id: str = Depends(get_unique_id, use_cache=False), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), ) -> PydanticResponse[SimpleMultiBody[AnalysisSummary]]: """Start a new analysis for the given existing protocol. @@ -713,6 +729,14 @@ async def create_protocol_analysis( raise ProtocolNotFound(detail=f"Protocol {protocolId} not found").as_error( status.HTTP_404_NOT_FOUND ) + + rtp_files = request_body.data.runTimeParameterFiles if request_body else {} + + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + try: ( analysis_summaries, @@ -722,7 +746,7 @@ async def create_protocol_analysis( analysis_id=analysis_id, force_analyze=request_body.data.forceReAnalyze if request_body else False, rtp_values=request_body.data.runTimeParameterValues if request_body else {}, - rtp_files=request_body.data.runTimeParameterFiles if request_body else {}, + rtp_files=rtp_paths, protocol_resource=protocol_store.get(protocol_id=protocolId), analysis_store=analysis_store, analyses_manager=analyses_manager, diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 1ed03b44cd7..9fea6851f33 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -4,6 +4,7 @@ """ import logging from datetime import datetime +from pathlib import Path from textwrap import dedent from typing import Optional, Union, Callable from typing_extensions import Literal @@ -12,7 +13,13 @@ from pydantic import BaseModel, Field from opentrons_shared_data.errors import ErrorCodes +from opentrons.protocol_engine.types import CSVRuntimeParamPaths +from robot_server.data_files.dependencies import ( + get_data_files_directory, + get_data_files_store, +) +from robot_server.data_files.data_files_store import DataFilesStore from robot_server.errors.error_responses import ErrorDetails, ErrorBody from robot_server.protocols.protocol_models import ProtocolKind from robot_server.service.dependencies import get_current_time, get_unique_id @@ -149,6 +156,8 @@ async def create_run( quick_transfer_run_auto_deleter: RunAutoDeleter = Depends( get_quick_transfer_run_auto_deleter ), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), check_estop: bool = Depends(require_estop_in_good_state), deck_configuration_store: DeckConfigurationStore = Depends( get_deck_configuration_store @@ -166,6 +175,8 @@ async def create_run( run_auto_deleter: An interface to delete old resources to make room for the new run. quick_transfer_run_auto_deleter: An interface to delete old quick-transfer + data_files_directory: Persistence directory for data files. + data_files_store: Database of data file resources. resources to make room for the new run. check_estop: Dependency to verify the estop is in a valid state. deck_configuration_store: Dependency to fetch the deck configuration. @@ -179,6 +190,15 @@ async def create_run( rtp_files = ( request_body.data.runTimeParameterFiles if request_body is not None else None ) + + rtp_paths: Optional[CSVRuntimeParamPaths] = None + # TODO(jbl 2024-08-02) raise the proper error if file ids don't exist + if rtp_files: + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + protocol_resource = None deck_configuration = await deck_configuration_store.get_deck_configuration() @@ -209,7 +229,7 @@ async def create_run( labware_offsets=offsets, deck_configuration=deck_configuration, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_paths, protocol=protocol_resource, notify_publishers=notify_publishers, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index c5cacbb7571..0c9b24228a3 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -13,8 +13,8 @@ Command, ) from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, PrimitiveRunTimeParamValuesType, + CSVRuntimeParamPaths, ) from robot_server.protocols.protocol_store import ProtocolResource @@ -159,7 +159,7 @@ async def create( labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], ) -> Union[Run, BadRun]: @@ -172,7 +172,7 @@ async def create( deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. notify_publishers: Utilized by the engine to notify publishers of state changes. run_time_param_values: Any runtime parameter values to set. - run_time_param_files: Any runtime parameter values to set. + run_time_param_paths: Any runtime filepath to set. protocol: The protocol to load the runner with, if any. Returns: @@ -197,7 +197,7 @@ async def create( deck_configuration=deck_configuration, protocol=protocol, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, notify_publishers=notify_publishers, ) run_resource = self._run_store.insert( diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index db068870915..1860e86d3e8 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -19,9 +19,9 @@ CommandNote, ) from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, RunTimeParameter, PrimitiveRunTimeParamValuesType, + CSVRunTimeParamFilesType, ) from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel @@ -255,7 +255,7 @@ class RunCreate(BaseModel): ) runTimeParameterFiles: Optional[CSVRunTimeParamFilesType] = Field( None, - description="Key-fileId pairs of CSV run-time parameters defined in a protocol.", + description="Key-fileId pairs of CSV run-time parameters defined in a run.", ) diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 953c9758cb1..13049d3b780 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -5,9 +5,9 @@ from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, PostRunHardwareState, RunTimeParameter, + CSVRuntimeParamPaths, ) from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -192,7 +192,8 @@ async def create( notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, - run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, + # TODO(jbl 2024-08-02) combine this with run_time_param_values now that theres no ambiguity with Paths + run_time_param_paths: Optional[CSVRuntimeParamPaths] = None, ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. @@ -203,7 +204,7 @@ async def create( notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. run_time_param_values: Any runtime parameter values to set. - run_time_param_files: Any runtime parameter files to set. + run_time_param_paths: Any runtime filepath to set. Returns: The initial equipment and status summary of the engine. @@ -249,7 +250,7 @@ async def create( await self.run_orchestrator.load( protocol.source, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, parse_mode=ParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) else: diff --git a/robot-server/settings_schema.json b/robot-server/settings_schema.json index 43878e4833a..a3f12670125 100644 --- a/robot-server/settings_schema.json +++ b/robot-server/settings_schema.json @@ -6,24 +6,32 @@ "simulator_configuration_file_path": { "title": "Simulator Configuration File Path", "description": "Path to a json file that describes the hardware simulator.", - "env_names": ["ot_robot_server_simulator_configuration_file_path"], + "env_names": [ + "ot_robot_server_simulator_configuration_file_path" + ], "type": "string" }, "notification_server_subscriber_address": { "title": "Notification Server Subscriber Address", "description": "The endpoint to subscribe to notification server topics.", "default": "tcp://localhost:5555", - "env_names": ["ot_robot_server_notification_server_subscriber_address"], + "env_names": [ + "ot_robot_server_notification_server_subscriber_address" + ], "type": "string" }, "persistence_directory": { "title": "Persistence Directory", "description": "A directory for the server to store things persistently across boots. If this directory doesn't already exist, the server will create it. If this is the string `automatically_make_temporary`, the server will use a fresh temporary directory (effectively not persisting anything).\n\nNote that the `opentrons` library is also responsible for persisting certain things, and it has its own configuration.", "default": "automatically_make_temporary", - "env_names": ["ot_robot_server_persistence_directory"], + "env_names": [ + "ot_robot_server_persistence_directory" + ], "anyOf": [ { - "enum": ["automatically_make_temporary"], + "enum": [ + "automatically_make_temporary" + ], "type": "string" }, { @@ -37,7 +45,9 @@ "description": "The maximum number of runs to allow HTTP clients to create before auto-deleting old ones.", "default": 20, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_runs"], + "env_names": [ + "ot_robot_server_maximum_runs" + ], "type": "integer" }, "maximum_unused_protocols": { @@ -45,7 +55,9 @@ "description": "The maximum number of \"unused protocols\" to allow before auto-deleting old ones. A protocol is \"unused\" if it isn't used by any run that currently exists.", "default": 5, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_unused_protocols"], + "env_names": [ + "ot_robot_server_maximum_unused_protocols" + ], "type": "integer" }, "maximum_quick_transfer_protocols": { @@ -53,9 +65,21 @@ "description": "The maximum number of \"quick transfer protocols\" to allow before auto-deleting old ones.", "default": 20, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_quick_transfer_protocols"], + "env_names": [ + "ot_robot_server_maximum_quick_transfer_protocols" + ], + "type": "integer" + }, + "maximum_data_files": { + "title": "Maximum Data Files", + "description": "The maximum number of data files to allow before auto-deleting old ones.", + "default": 50, + "exclusiveMinimum": 0, + "env_names": [ + "ot_robot_server_maximum_data_files" + ], "type": "integer" } }, "additionalProperties": false -} +} \ No newline at end of file diff --git a/robot-server/tests/integration/data_files/sample_plates.csv b/robot-server/tests/integration/data_files/sample_plates.csv new file mode 100644 index 00000000000..09ddcf31c80 --- /dev/null +++ b/robot-server/tests/integration/data_files/sample_plates.csv @@ -0,0 +1,41 @@ +Sample_Plate, Sample_well,InitialVol,InitialConc,TargetConc +sample_plate,A1,5,35.6,1 +sample_plate,B1,5,31.5,1 +sample_plate,C1,5,33.7,1 +sample_plate,D1,5,28.9,1 +sample_plate,E1,5,28.9,1 +sample_plate,F1,5,26.5,1 +sample_plate,G1,5,26.2,1 +sample_plate,H1,5,18.9,1 +sample_plate,A2,5,12.5,1 +sample_plate,B2,5,18.4,1 +sample_plate,C2,5,13,1 +sample_plate,D2,5,14.8,1 +sample_plate,E2,5,13.3,1 +sample_plate,F2,5,12.8,1 +sample_plate,G2,5,15.2,1 +sample_plate,H2,5,8.89,1 +sample_plate,A3,5,14,1 +sample_plate,B3,5,19.5,1 +sample_plate,C3,5,18.9,1 +sample_plate,D3,5,21,1 +sample_plate,E3,5,23.8,1 +sample_plate,F3,5,12.9,1 +sample_plate,G3,5,16.7,1 +sample_plate,H3,5,20,1 +sample_plate,A4,10,2.88,1 +sample_plate,B4,10,2.36,1 +sample_plate,C4,10,2.04,1 +sample_plate,D4,10,2.57,1 +sample_plate,E4,10,2.47,1 +sample_plate,F4,10,2.09,1 +sample_plate,G4,10,2.47,1 +sample_plate,H4,10,3.18,1 +sample_plate,A5,10,3.2,1 +sample_plate,B5,10,4.12,1 +sample_plate,C5,10,3.18,1 +sample_plate,D5,10,2.6,1 +sample_plate,E5,10,4.47,1 +sample_plate,F5,10,2.99,1 +sample_plate,G5,10,2.97,1 +sample_plate,H5,10,2.93,1 \ No newline at end of file diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml index d915d599ba5..399fc6e445c 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml @@ -98,7 +98,7 @@ stages: description: A CSV file that contains wells to use for pipetting file: id: '{csv_file_id}' - name: '' + name: 'sample_record.csv' - name: Wait until analysis is completed max_retries: 5 diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index a616a50cc66..f029e945e20 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -17,6 +17,23 @@ stages: json: protocol_id: data.id + - name: Upload sample_plates.csv file + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_plates.csv' + response: + save: + json: + data_file_id: data.id + status_code: 201 + json: + data: + id: !anystr + name: "sample_plates.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - name: Create run from protocol request: url: '{ot2_server_base_url}/runs' @@ -29,6 +46,8 @@ stages: volume: 10.23 dry_run: True pipette: flex_8channel_50 + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_id}' response: status_code: 201 save: @@ -92,6 +111,9 @@ stages: variableName: liq_handling_csv_file description: A CSV file that contains wells to use for pipetting type: csv_file + file: + id: '{data_file_id}' + name: sample_plates.csv liquids: [] protocolId: '{protocol_id}' diff --git a/robot-server/tests/protocols/test_analyses_manager.py b/robot-server/tests/protocols/test_analyses_manager.py index 46c46a5243f..26514a86983 100644 --- a/robot-server/tests/protocols/test_analyses_manager.py +++ b/robot-server/tests/protocols/test_analyses_manager.py @@ -97,12 +97,12 @@ async def test_initialize_analyzer( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"sample_count": 123}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my_file": Path("file-path")}, ) decoy.verify( await analyzer.load_orchestrator( run_time_param_values={"sample_count": 123}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my_file": Path("file-path")}, ) ) @@ -145,7 +145,7 @@ async def test_raises_error_and_saves_result_if_initialization_errors( decoy.when( await analyzer.load_orchestrator( run_time_param_values={"sample_count": 123}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_raise(raised_exception) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -157,7 +157,7 @@ async def test_raises_error_and_saves_result_if_initialization_errors( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"sample_count": 123}, - run_time_param_files={}, + run_time_param_paths={}, ) decoy.verify( await analysis_store.save_initialization_failed_analysis( diff --git a/robot-server/tests/protocols/test_protocol_analyzer.py b/robot-server/tests/protocols/test_protocol_analyzer.py index a5eb40b95bc..87108ff75f8 100644 --- a/robot-server/tests/protocols/test_protocol_analyzer.py +++ b/robot-server/tests/protocols/test_protocol_analyzer.py @@ -102,7 +102,7 @@ async def test_load_orchestrator( ).then_return(run_orchestrator) await subject.load_orchestrator( run_time_param_values={"rtp_var": 123}, - run_time_param_files={"csv_param": "file-id"}, + run_time_param_paths={"csv_param": Path("file-path")}, ) decoy.verify( @@ -110,7 +110,7 @@ async def test_load_orchestrator( protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values={"rtp_var": 123}, - run_time_param_files={"csv_param": "file-id"}, + run_time_param_paths={"csv_param": Path("file-path")}, ), times=1, ) @@ -176,7 +176,7 @@ async def test_analyze( analysis_store=analysis_store, protocol_resource=protocol_resource ) await subject.load_orchestrator( - run_time_param_values={"rtp_var": 123}, run_time_param_files={} + run_time_param_values={"rtp_var": 123}, run_time_param_paths={} ) decoy.when(await orchestrator.run(deck_configuration=[],)).then_return( protocol_runner.RunResult( @@ -275,7 +275,7 @@ async def test_analyze_updates_pending_on_error( datetime(year=2023, month=3, day=3) ) await subject.load_orchestrator( - run_time_param_values={"rtp_var": 123}, run_time_param_files={} + run_time_param_values={"rtp_var": 123}, run_time_param_paths={} ) await subject.analyze( analysis_id="analysis-id", diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index e1e9968b232..89c41341941 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -12,7 +12,7 @@ PrimitiveRunTimeParamValuesType, NumberParameter, CSVParameter, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) from opentrons.protocols.api_support.types import APIVersion @@ -30,6 +30,7 @@ BufferedFile, ) +from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo from robot_server.data_files.models import DataFile from robot_server.errors.error_responses import ApiError from robot_server.protocols.analyses_manager import AnalysesManager @@ -115,6 +116,12 @@ def analyses_manager(decoy: Decoy) -> AnalysesManager: return decoy.mock(cls=AnalysesManager) +@pytest.fixture +def data_files_store(decoy: Decoy) -> DataFilesStore: + """Get a mocked out DataFilesStore.""" + return decoy.mock(cls=DataFilesStore) + + @pytest.fixture def protocol_auto_deleter(decoy: Decoy) -> ProtocolAutoDeleter: """Get a mocked out AutoDeleter.""" @@ -456,7 +463,7 @@ async def test_create_existing_protocol( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -572,7 +579,7 @@ async def test_create_protocol( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -625,6 +632,7 @@ async def test_create_new_protocol_with_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, protocol_reader: ProtocolReader, file_reader_writer: FileReaderWriter, file_hasher: FileHasher, @@ -684,6 +692,15 @@ async def test_create_new_protocol_with_run_time_params( ) ).then_return([buffered_file]) + decoy.when(data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="file.abc", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("abc123") decoy.when( @@ -702,7 +719,7 @@ async def test_create_new_protocol_with_run_time_params( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={"my_csv_file": "file-id"}, + run_time_param_paths={"my_csv_file": Path("/dev/null/file-id/file.abc")}, ) ).then_return(analyzer) decoy.when( @@ -721,6 +738,8 @@ async def test_create_new_protocol_with_run_time_params( protocol_directory=protocol_directory, protocol_store=protocol_store, analysis_store=analysis_store, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), file_reader_writer=file_reader_writer, protocol_reader=protocol_reader, file_hasher=file_hasher, @@ -817,7 +836,7 @@ async def test_create_existing_protocol_with_no_previous_analysis( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) @@ -865,6 +884,7 @@ async def test_create_existing_protocol_with_different_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, protocol_reader: ProtocolReader, file_reader_writer: FileReaderWriter, file_hasher: FileHasher, @@ -929,6 +949,14 @@ async def test_create_existing_protocol_with_different_run_time_params( files=[protocol_file] # type: ignore[list-item] ) ).then_return([buffered_file]) + decoy.when(data_files_store.get("csv-file-id")).then_return( + DataFileInfo( + id="123", + name="file.abc", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("a_b_c") decoy.when(protocol_store.get_all()).then_return([]) decoy.when(protocol_store.get_id_by_hash("a_b_c")).then_return("the-og-proto-id") @@ -944,7 +972,9 @@ async def test_create_existing_protocol_with_different_run_time_params( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={"my_csv_file": "csv-file-id"}, + run_time_param_paths={ + "my_csv_file": Path("/dev/null/csv-file-id/file.abc") + }, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -970,6 +1000,8 @@ async def test_create_existing_protocol_with_different_run_time_params( protocol_directory=protocol_directory, protocol_store=protocol_store, analysis_store=analysis_store, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), file_reader_writer=file_reader_writer, protocol_reader=protocol_reader, file_hasher=file_hasher, @@ -1075,7 +1107,7 @@ async def test_create_existing_protocol_with_same_run_time_params( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1200,7 +1232,7 @@ async def test_create_existing_protocol_with_pending_analysis_raises( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1618,7 +1650,7 @@ async def test_create_protocol_analyses_with_same_rtp_values( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values=rtp_values, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1648,6 +1680,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, analyses_manager: AnalysesManager, ) -> None: """It should start a new analysis for the new rtp values.""" @@ -1656,8 +1689,8 @@ async def test_update_protocol_analyses_with_new_rtp_values( "dry_run": True, "mount": "left", } - rtp_files: CSVRunTimeParamFilesType = { - "csv_param": "file-id", + rtp_files: CSVRuntimeParamPaths = { + "csv_param": Path("/dev/null/file-id/foo.csv"), } protocol_source = ProtocolSource( directory=Path("/dev/null"), @@ -1701,6 +1734,14 @@ async def test_update_protocol_analyses_with_new_rtp_values( variableName="csv_param", file=FileInfo(id="file-id", name=""), ) + decoy.when(data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="foo.csv", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) decoy.when(protocol_store.get(protocol_id="protocol-id")).then_return( stored_protocol_resource @@ -1714,7 +1755,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_files, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1742,12 +1783,15 @@ async def test_update_protocol_analyses_with_new_rtp_values( protocolId="protocol-id", request_body=RequestModel( data=AnalysisRequest( - runTimeParameterValues=rtp_values, runTimeParameterFiles=rtp_files + runTimeParameterValues=rtp_values, + runTimeParameterFiles={"csv_param": "file-id"}, ) ), protocol_store=protocol_store, analysis_store=analysis_store, analyses_manager=analyses_manager, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), analysis_id="analysis-id-2", ) assert result.content.data == [ @@ -1809,7 +1853,7 @@ async def test_update_protocol_analyses_with_forced_reanalysis( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when( @@ -1915,7 +1959,7 @@ async def test_create_protocol_kind_quick_transfer( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when( diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index fd1cdd8b58a..2e9b532ad93 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -8,6 +8,8 @@ from opentrons.protocol_engine import LabwareOffsetCreate, types as pe_types from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig +from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo + from robot_server.errors.error_responses import ApiError from robot_server.runs.error_recovery_models import ErrorRecoveryPolicy from robot_server.service.json_api import ( @@ -50,6 +52,12 @@ def mock_notify_publishers() -> None: return None +@pytest.fixture +def mock_data_files_store(decoy: Decoy) -> DataFilesStore: + """Get a mock DataFilesStore.""" + return decoy.mock(cls=DataFilesStore) + + @pytest.fixture def labware_offset_create() -> LabwareOffsetCreate: """Get a labware offset create request value object.""" @@ -66,6 +74,7 @@ async def test_create_run( mock_run_auto_deleter: RunAutoDeleter, labware_offset_create: pe_types.LabwareOffsetCreate, mock_deck_configuration_store: DeckConfigurationStore, + mock_data_files_store: DataFilesStore, ) -> None: """It should be able to create a basic run.""" run_id = "run-id" @@ -97,7 +106,7 @@ async def test_create_run( deck_configuration=[], protocol=None, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -107,6 +116,8 @@ async def test_create_run( data=RunCreate(labwareOffsets=[labware_offset_create]) ), run_data_manager=mock_run_data_manager, + data_files_store=mock_data_files_store, + data_files_directory=Path("/dev/null"), run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, @@ -126,6 +137,7 @@ async def test_create_protocol_run( mock_run_data_manager: RunDataManager, mock_run_auto_deleter: RunAutoDeleter, mock_deck_configuration_store: DeckConfigurationStore, + mock_data_files_store: DataFilesStore, ) -> None: """It should be able to create a protocol run.""" run_id = "run-id" @@ -163,6 +175,14 @@ async def test_create_protocol_run( liquids=[], hasEverEnteredErrorRecovery=False, ) + decoy.when(mock_data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="abc.xyz", + file_hash="987", + created_at=datetime(month=1, day=2, year=2024), + ) + ) decoy.when( await mock_deck_configuration_store.get_deck_configuration() ).then_return([]) @@ -178,7 +198,7 @@ async def test_create_protocol_run( deck_configuration=[], protocol=protocol_resource, run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my-csv-param": Path("/dev/null/file-id/abc.xyz")}, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -188,11 +208,13 @@ async def test_create_protocol_run( data=RunCreate( protocolId="protocol-id", runTimeParameterValues={"foo": "bar"}, - runTimeParameterFiles={"my_file": "file-id"}, + runTimeParameterFiles={"my-csv-param": "file-id"}, ) ), protocol_store=mock_protocol_store, run_data_manager=mock_run_data_manager, + data_files_store=mock_data_files_store, + data_files_directory=Path("/dev/null"), run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, @@ -249,7 +271,7 @@ async def test_create_run_conflict( deck_configuration=[], protocol=None, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index a369f7f47b0..309c82747a2 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -4,6 +4,8 @@ import pytest from decoy import Decoy, matchers +from pathlib import Path + from opentrons.protocol_engine import ( EngineStatus, StateSummary, @@ -16,8 +18,8 @@ LoadedPipette, LoadedModule, LabwareOffset, + Liquid, ) -from opentrons.protocol_engine import Liquid from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.protocol_engine.types import BooleanParameter, CSVParameter from opentrons.protocol_runner import RunResult @@ -168,7 +170,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -183,6 +185,8 @@ async def test_create( ) ).then_return(run_resource) + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) + result = await subject.create( run_id=run_id, created_at=created_at, @@ -190,7 +194,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) @@ -244,7 +248,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"xyzzy": Path("zork")}, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -274,7 +278,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"xyzzy": Path("zork")}, notify_publishers=mock_notify_publishers, ) @@ -313,7 +317,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) @@ -326,7 +330,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) @@ -774,7 +778,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -794,7 +798,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) From 1edec95da265ca42750bea42adfc5656b4ad5a50 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 5 Aug 2024 09:44:59 -0400 Subject: [PATCH 04/39] fix(app): fix module wizard missing copy (#15881) Closes RQA-2895 --- app/src/organisms/ModuleWizardFlows/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index f0b542b4069..9cc33d05688 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -299,7 +299,9 @@ export const ModuleWizardFlows = ( ) } else if (isExiting) { modalContent = ( - + ) } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { modalContent = From d84b3499056ddec2875e6ed5d46abde004c6326e Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 5 Aug 2024 10:11:30 -0400 Subject: [PATCH 05/39] fix(app)switch navlink to useNavigate for tabas in run setup (#15885) * fix(app)switch navlink to useNavigate for tabas in run setup --- .../ProtocolRunRunTimeParameters.tsx | 11 +- app/src/organisms/RunPreview/index.tsx | 5 +- .../Devices/ProtocolRunDetails/index.tsx | 118 ++++++++++-------- 3 files changed, 76 insertions(+), 58 deletions(-) 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/RunPreview/index.tsx b/app/src/organisms/RunPreview/index.tsx index 9ffee161e5f..420d820790e 100644 --- a/app/src/organisms/RunPreview/index.tsx +++ b/app/src/organisms/RunPreview/index.tsx @@ -13,10 +13,11 @@ import { DISPLAY_NONE, Flex, InfoScreen, + LegacyStyledText, + OVERFLOW_SCROLL, POSITION_FIXED, PrimaryButton, SPACING, - LegacyStyledText, TYPOGRAPHY, } from '@opentrons/components' @@ -122,7 +123,7 @@ export const RunPreviewComponent = ( flexDirection={DIRECTION_COLUMN} height="28rem" width="100%" - overflowY="scroll" + overflowY={OVERFLOW_SCROLL} gridGap={SPACING.spacing8} padding={SPACING.spacing16} > diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 8d21ae21203..2935bc86100 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import isEmpty from 'lodash/isEmpty' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' -import { NavLink, Navigate, useParams } from 'react-router-dom' +import { NavLink, Navigate, useParams, useNavigate } from 'react-router-dom' import styled, { css } from 'styled-components' import { @@ -11,11 +11,10 @@ import { COLORS, DIRECTION_COLUMN, Flex, + LegacyStyledText, OVERFLOW_SCROLL, POSITION_RELATIVE, - SIZE_6, SPACING, - LegacyStyledText, TYPOGRAPHY, useHoverTooltip, } from '@opentrons/components' @@ -144,7 +143,7 @@ export function ProtocolRunDetails(): JSX.Element | null { robotName={robot.name} > - - - + + + { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') const currentRunId = useCurrentRunId() + const navigate = useNavigate() const disabled = currentRunId !== runId const tabDisabledReason = `${t('setup')} ${t( 'not_available_for_a_completed_run' )}` + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'setup') + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`) + }, [disabled, navigate, robotName, runId]) + return ( - <> - - {currentRunId !== runId ? ( - // redirect to run preview if not current run - - ) : null} - + ) } interface ParametersTabProps { robotName: string runId: string + protocolRunDetailsTab: ProtocolRunDetailsTab } const ParametersTab = (props: ParametersTabProps): JSX.Element | null => { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') - const disabled = false - const tabDisabledReason = '' + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const navigate = useNavigate() + const disabled = mostRecentAnalysis == null + + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'runtime-parameters') { + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`, { + replace: true, + }) + } + }, [disabled, navigate, robotName, runId]) return ( - <> - - {disabled ? ( - - ) : null} - + ) } interface ModuleControlsTabProps { robotName: string runId: string + protocolRunDetailsTab: ProtocolRunDetailsTab } const ModuleControlsTab = ( props: ModuleControlsTabProps ): JSX.Element | null => { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') const currentRunId = useCurrentRunId() const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( runId ) const { isRunStill } = useRunStatuses() + const navigate = useNavigate() const disabled = currentRunId !== runId || !isRunStill const tabDisabledReason = `${t('module_controls')} ${t( @@ -335,22 +349,18 @@ const ModuleControlsTab = ( : 'not_available_for_a_run_in_progress' )}` - return isEmpty(moduleRenderInfoForProtocolById) ? null : ( - <> - - {disabled ? ( - // redirect to run preview if not current run + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'module-controls') + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`) + }, [disabled, navigate, robotName, runId]) - - ) : null} - + return isEmpty(moduleRenderInfoForProtocolById) ? null : ( + ) } From f93ac416187b5f14ce1769be6c44afabf9ce5a84 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 5 Aug 2024 11:21:35 -0400 Subject: [PATCH 06/39] fix(app): fix desktop post-run drop tip wiz crash after tip removal (#15887) Closes RQA-2902 Refactor useTipAttachementStatus to provide only one pipette with tip at a time. --- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 10 ++-- .../__tests__/ProtocolRunHeader.test.tsx | 20 ++++++-- .../DropTipWizardFlows/TipsAttachedModal.tsx | 11 +++-- .../__tests__/DropTipWizardFlows.test.tsx | 46 +++++++++---------- .../__tests__/TipsAttachedModal.test.tsx | 22 ++++----- .../organisms/DropTipWizardFlows/index.tsx | 20 ++++---- .../RecoveryOptions/ManageTips.tsx | 9 ++-- .../__tests__/ManageTips.test.tsx | 2 +- app/src/pages/RunSummary/index.tsx | 20 ++++---- 9 files changed, 83 insertions(+), 77 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 9f4bef400ee..6a704c96699 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -205,7 +205,7 @@ export function ProtocolRunHeader({ determineTipStatus, resetTipStatus, setTipStatusResolved, - pipettesWithTip, + aPipetteWithTip, } = useTipAttachmentStatus({ runId, runRecord, @@ -421,7 +421,7 @@ export function ProtocolRunHeader({ ) : null} @@ -496,11 +496,11 @@ export function ProtocolRunHeader({ robotName={robotName} /> ) : null} - {showDTWiz && mostRecentRunId === runId ? ( + {showDTWiz && aPipetteWithTip != null ? ( setTipStatusResolved().then(toggleDTWiz)} /> ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 157538c9ff8..70b16c61b55 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -14,7 +14,6 @@ import { RUN_STATUS_SUCCEEDED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, } from '@opentrons/api-client' import { useHost, @@ -88,6 +87,7 @@ import { useNotifyRunQuery, useCurrentRunId } from '../../../../resources/runs' import { useDropTipWizardFlows, useTipAttachmentStatus, + DropTipWizardFlows, } from '../../../DropTipWizardFlows' import { useErrorRecoveryFlows, @@ -340,10 +340,7 @@ describe('ProtocolRunHeader', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) vi.mocked(useHost).mockReturnValue({} as any) vi.mocked(useTipAttachmentStatus).mockReturnValue({ - pipettesWithTip: [ - instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, - ], + aPipetteWithTip: instrumentsResponseLeftPipetteFixture, areTipsAttached: true, determineTipStatus: mockDetermineTipStatus, resetTipStatus: vi.fn(), @@ -384,6 +381,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ProtocolDropTipModal).mockReturnValue(
MOCK_DROP_TIP_MODAL
) + vi.mocked(DropTipWizardFlows).mockReturnValue( +
MOCK_DROP_TIP_WIZARD_FLOWS
+ ) }) afterEach(() => { @@ -1076,4 +1076,14 @@ describe('ProtocolRunHeader', () => { render() screen.getByText('MOCK_ERROR_RECOVERY') }) + + it('renders DropTipWizardFlows when conditions are met', () => { + vi.mocked(useDropTipWizardFlows).mockReturnValue({ + showDTWiz: true, + toggleDTWiz: vi.fn(), + }) + + render() + screen.getByText('MOCK_DROP_TIP_WIZARD_FLOWS') + }) }) diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 0cb1872b196..71de14567fa 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import capitalize from 'lodash/capitalize' -import head from 'lodash/head' import NiceModal, { useModal } from '@ebay/nice-modal-react' import { Trans, useTranslation } from 'react-i18next' @@ -23,7 +22,7 @@ import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' import type { PipetteWithTip } from '.' interface TipsAttachedModalProps { - pipettesWithTip: PipetteWithTip[] + aPipetteWithTip: PipetteWithTip host: HostConfig | null setTipStatusResolved: (onEmpty?: () => void) => Promise } @@ -38,11 +37,11 @@ export const handleTipsAttachedModal = ( const TipsAttachedModal = NiceModal.create( (props: TipsAttachedModalProps): JSX.Element => { - const { pipettesWithTip, host, setTipStatusResolved } = props + const { aPipetteWithTip, host, setTipStatusResolved } = props const { t } = useTranslation(['drop_tip_wizard']) const modal = useModal() - const { mount, specs } = head(pipettesWithTip) as PipetteWithTip + const { mount, specs } = aPipetteWithTip const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() const tipsAttachedHeader: ModalHeaderBaseProps = { @@ -57,7 +56,9 @@ const TipsAttachedModal = NiceModal.create( } const is96Channel = specs.channels === 96 - const displayMountText = is96Channel ? '96-Channel' : capitalize(mount) + const displayMountText = is96Channel + ? '96-Channel' + : capitalize(mount as string) return ( diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx index 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( - ) }) 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/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx index daa2a7e114f..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( @@ -51,12 +53,14 @@ export function SetupLiquids({ )} - + { + setLiquidSetupConfirmed(true) + }} + disabled={isLiquidSetupConfirmed} + > + {t('confirm_placements')} +
) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 70b16c61b55..872dff5771f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -97,7 +97,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 +155,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' @@ -215,6 +218,7 @@ const mockDoorStatus = { doorRequiredClosedForProtocol: true, }, } +let mockMissingSteps: MissingSteps = [] const render = () => { return renderWithProviders( @@ -224,6 +228,7 @@ const render = () => { robotName={ROBOT_NAME} runId={RUN_ID} makeHandleJumpToStep={vi.fn(() => vi.fn())} + missingSetupSteps={mockMissingSteps} /> , { i18nInstance: i18n } @@ -240,7 +245,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 +272,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, }) 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/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 8182a8b73b3..0edc5a1ad1a 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -52,11 +52,17 @@ const mockRefetch = vi.fn() const mockCreateLiveCommand = vi.fn() const render = () => { + let confirmed = false + const setIsConfirmed = vi.fn((ready: boolean) => { + confirmed = ready + }) return renderWithProviders( , { diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index fa4d3926fdb..1210c1887df 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -22,6 +22,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + Chip, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -37,7 +38,7 @@ import { useModulesQuery, } from '@opentrons/react-api-client' -import { FloatingActionButton } from '../../atoms/buttons' +import { FloatingActionButton, SmallButton } from '../../atoms/buttons' import { ODDBackButton } from '../../molecules/ODDBackButton' import { getTopPortalEl } from '../../App/portal' import { Modal } from '../../molecules/Modal' @@ -77,11 +78,15 @@ const LabwareThumbnail = styled.svg` export interface ProtocolSetupLabwareProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLabware({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLabwareProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [showMapView, setShowMapView] = React.useState(false) @@ -247,12 +252,34 @@ export function ProtocolSetupLabware({ , getTopPortalEl() )} - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + ) => { - return renderWithProviders(, { - i18nInstance: i18n, +describe('ProtocolSetupLiquids', () => { + let isConfirmed = false + const setIsConfirmed = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed }) -} -describe('ProtocolSetupLiquids', () => { + const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) + } + let props: React.ComponentProps beforeEach(() => { - props = { runId: RUN_ID_1, setSetupScreen: vi.fn() } + props = { + runId: RUN_ID_1, + setSetupScreen: vi.fn(), + isConfirmed, + setIsConfirmed, + } vi.mocked(parseLiquidsInLoadOrder).mockReturnValue( MOCK_LIQUIDS_IN_LOAD_ORDER ) diff --git a/app/src/organisms/ProtocolSetupLiquids/index.tsx b/app/src/organisms/ProtocolSetupLiquids/index.tsx index 1fb10cdb79d..883054c6963 100644 --- a/app/src/organisms/ProtocolSetupLiquids/index.tsx +++ b/app/src/organisms/ProtocolSetupLiquids/index.tsx @@ -5,6 +5,7 @@ import { BORDERS, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, Flex, Icon, JUSTIFY_FLEX_END, @@ -12,6 +13,7 @@ import { StyledText, TYPOGRAPHY, JUSTIFY_SPACE_BETWEEN, + Chip, } from '@opentrons/components' import { parseLiquidsInLoadOrder, @@ -19,6 +21,8 @@ import { } from '@opentrons/api-client' import { MICRO_LITERS } from '@opentrons/shared-data' import { ODDBackButton } from '../../molecules/ODDBackButton' +import { SmallButton } from '../../atoms/buttons' + import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getTotalVolumePerLiquidId } from '../Devices/ProtocolRun/SetupLiquids/utils' import { LiquidDetails } from './LiquidDetails' @@ -29,13 +33,17 @@ import type { SetupScreens } from '../../pages/ProtocolSetup' export interface ProtocolSetupLiquidsProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLiquids({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLiquidsProps): JSX.Element { - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const protocolData = useMostRecentCompletedAnalysis(runId) const liquidsInLoadOrder = parseLiquidsInLoadOrder( protocolData?.liquids ?? [], @@ -43,12 +51,34 @@ export function ProtocolSetupLiquids({ ) return ( <> - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + > + lpcDisabledReason: string | null + launchLPC: () => void + LPCWizard: JSX.Element | null + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void +} + +export function ProtocolSetupOffsets({ + runId, + setSetupScreen, + isConfirmed, + setIsConfirmed, + launchLPC, + lpcDisabledReason, + LPCWizard, +}: ProtocolSetupOffsetsProps): JSX.Element { + const { t } = useTranslation('protocol_setup') + const { makeSnackbar } = useToaster() + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const makeDisabledReasonSnackbar = (): void => { + if (lpcDisabledReason != null) { + makeSnackbar(lpcDisabledReason) + } + } + + const labwareDefinitions = getLabwareDefinitionsFromCommands( + mostRecentAnalysis?.commands ?? [] + ) + const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const currentOffsets = runRecord?.data?.labwareOffsets ?? [] + const sortedOffsets: LabwareOffset[] = + currentOffsets.length > 0 + ? currentOffsets + .map(offset => ({ + ...offset, + // convert into date to sort + createdAt: new Date(offset.createdAt), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(offset => ({ + ...offset, + // convert back into string + createdAt: offset.createdAt.toISOString(), + })) + : [] + const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) + return ( + <> + {LPCWizard} + {LPCWizard == null && ( + <> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + + + { + if (lpcDisabledReason != null) { + makeDisabledReasonSnackbar() + } else { + launchLPC() + } + }} + /> + + )} + + ) +} diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 2935bc86100..62798b55b4f 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -10,6 +10,8 @@ import { Box, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, + JUSTIFY_SPACE_AROUND, Flex, LegacyStyledText, OVERFLOW_SCROLL, @@ -29,7 +31,11 @@ import { } from '../../../organisms/Devices/hooks' import { ProtocolRunHeader } from '../../../organisms/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '../../../organisms/RunPreview' -import { ProtocolRunSetup } from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { + ProtocolRunSetup, + initialMissingSteps, +} from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { BackToTopButton } from '../../../organisms/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' import { useCurrentRunId } from '../../../resources/runs' @@ -134,7 +140,6 @@ export function ProtocolRunDetails(): JSX.Element | null { React.useEffect(() => { dispatch(fetchProtocols()) }, [dispatch]) - return robot != null ? ( + >(initialMissingSteps()) + const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -193,37 +202,68 @@ function PageContents(props: PageContentsProps): JSX.Element { setJumpedIndex(i) } const protocolRunDetailsContentByTab: { - [K in ProtocolRunDetailsTab]: JSX.Element | null + [K in ProtocolRunDetailsTab]: { + content: JSX.Element | null + backToTop: JSX.Element | null + } } = { - setup: ( - - ), - 'runtime-parameters': , - 'module-controls': ( - - ), - 'run-preview': ( - - ), + setup: { + content: ( + + ), + backToTop: ( + + + + ), + }, + 'runtime-parameters': { + content: , + backToTop: null, + }, + 'module-controls': { + content: ( + + ), + backToTop: null, + }, + 'run-preview': { + content: ( + + ), + backToTop: null, + }, } - - const protocolRunDetailsContent = protocolRunDetailsContentByTab[ - protocolRunDetailsTab - ] ?? ( + const tabDetails = protocolRunDetailsContentByTab[protocolRunDetailsTab] ?? { // default to the setup tab if no tab or nonexistent tab is passed as a param - - - ) + content: ( + + ), + backToTop: null, + } + const { content, backToTop } = tabDetails return ( <> @@ -232,6 +272,7 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} + missingSetupSteps={missingSteps} /> - {protocolRunDetailsContent} + {content} + {backToTop} ) } diff --git a/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx new file mode 100644 index 00000000000..1757704e597 --- /dev/null +++ b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + SPACING, + LegacyStyledText, +} from '@opentrons/components' + +import { SmallButton } from '../../atoms/buttons' +import { Modal } from '../../molecules/Modal' + +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' + +interface ConfirmSetupStepsCompleteModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} + +export function ConfirmSetupStepsCompleteModal({ + onCloseClick, + missingSteps, + onConfirmClick, +}: ConfirmSetupStepsCompleteModalProps): JSX.Element { + const { i18n, t } = useTranslation(['protocol_setup', 'shared']) + const modalHeader: ModalHeaderBaseProps = { + title: t('are_you_sure_you_want_to_proceed'), + hasExitIcon: true, + } + + const handleStartRun = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps), + })} + + + { + onCloseClick() + }} + /> + + + + + ) +} diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 1be58ae82f8..5479f4693bd 100644 --- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Route, MemoryRouter, Routes } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' import { when } from 'vitest-when' -import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' +import { vi, it, describe, expect, beforeEach } from 'vitest' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { @@ -39,10 +39,13 @@ import { ANALYTICS_PROTOCOL_RUN_ACTION } from '../../../redux/analytics' import { ProtocolSetupLiquids } from '../../../organisms/ProtocolSetupLiquids' import { getProtocolModulesInfo } from '../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' import { ProtocolSetupModulesAndDeck } from '../../../organisms/ProtocolSetupModulesAndDeck' +import { ProtocolSetupLabware } from '../../../organisms/ProtocolSetupLabware' +import { ProtocolSetupOffsets } from '../../../organisms/ProtocolSetupOffsets' import { getUnmatchedModulesForProtocol } from '../../../organisms/ProtocolSetupModulesAndDeck/utils' import { useLaunchLPC } from '../../../organisms/LabwarePositionCheck/useLaunchLPC' import { ConfirmCancelRunModal } from '../../../organisms/OnDeviceDisplay/RunningProtocol' import { mockProtocolModuleInfo } from '../../../organisms/ProtocolSetupInstruments/__fixtures__' +import { getIncompleteInstrumentCount } from '../../../organisms/ProtocolSetupInstruments/utils' import { useProtocolHasRunTimeParameters, useRunControls, @@ -51,6 +54,7 @@ import { import { useIsHeaterShakerInProtocol } from '../../../organisms/ModuleCard/hooks' import { useDeckConfigurationCompatibility } from '../../../resources/deck_configuration/hooks' import { ConfirmAttachedModal } from '../../../pages/ProtocolSetup/ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from '../../../pages/ProtocolSetup/ConfirmSetupStepsCompleteModal' import { ProtocolSetup } from '../../../pages/ProtocolSetup' import { useNotifyRunQuery } from '../../../resources/runs' import { ViewOnlyParameters } from '../../../organisms/ProtocolSetupParameters/ViewOnlyParameters' @@ -99,12 +103,15 @@ vi.mock('../../../organisms/ProtocolSetupParameters/ViewOnlyParameters') vi.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' ) +vi.mock('../../../organisms/ProtocolSetupInstruments/utils') vi.mock('../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck/utils') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../organisms/ProtocolSetupLiquids') +vi.mock('../../../organisms/ProtocolSetupLabware') +vi.mock('../../../organisms/ProtocolSetupOffsets') vi.mock('../../../organisms/ModuleCard/hooks') vi.mock('../../../redux/discovery/selectors') vi.mock('../ConfirmAttachedModal') @@ -112,6 +119,7 @@ vi.mock('../../../organisms/ToasterOven') vi.mock('../../../resources/deck_configuration/hooks') vi.mock('../../../resources/runs') vi.mock('../../../resources/deck_configuration') +vi.mock('../ConfirmSetupStepsCompleteModal') const render = (path = '/') => { return renderWithProviders( @@ -126,6 +134,12 @@ const render = (path = '/') => { ) } +const MockProtocolSetupLabware = vi.mocked(ProtocolSetupLabware) +const MockProtocolSetupLiquids = vi.mocked(ProtocolSetupLiquids) +const MockProtocolSetupOffsets = vi.mocked(ProtocolSetupOffsets) +const MockConfirmSetupStepsCompleteModal = vi.mocked( + ConfirmSetupStepsCompleteModal +) const ROBOT_NAME = 'fake-robot-name' const RUN_ID = 'my-run-id' const ROBOT_SERIAL_NUMBER = 'OT123' @@ -192,6 +206,30 @@ describe('ProtocolSetup', () => { beforeEach(() => { mockLaunchLPC = vi.fn() mockNavigate = vi.fn() + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) + MockConfirmSetupStepsCompleteModal.mockReturnValue( +
Mock ConfirmSetupStepsCompleteModal
+ ) vi.mocked(useLPCDisabledReason).mockReturnValue(null) vi.mocked(useAttachedModules).mockReturnValue([]) vi.mocked(useModuleCalibrationStatus).mockReturnValue({ complete: true }) @@ -290,10 +328,6 @@ describe('ProtocolSetup', () => { .thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent }) }) - afterEach(() => { - vi.resetAllMocks() - }) - it('should render text, image, and buttons', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('Prepare to run') @@ -305,9 +339,47 @@ describe('ProtocolSetup', () => { }) it('should play protocol when click play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByText('Labware Position Check')) + fireEvent.click(screen.getByText('Labware')) + fireEvent.click(screen.getByText('Liquids')) expect(mockPlay).toBeCalledTimes(0) fireEvent.click(screen.getByRole('button', { name: 'play' })) + expect(MockConfirmSetupStepsCompleteModal).toBeCalledTimes(0) expect(mockPlay).toBeCalledTimes(1) }) @@ -348,7 +420,25 @@ describe('ProtocolSetup', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('1 initial liquid') fireEvent.click(screen.getByText('Liquids')) - expect(vi.mocked(ProtocolSetupLiquids)).toHaveBeenCalled() + expect(MockProtocolSetupLiquids).toHaveBeenCalled() + }) + + it('should launch protocol setup labware screen when click labware', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + render(`/runs/${RUN_ID}/setup`) + fireEvent.click(screen.getByTestId('SetupButton_Labware')) + expect(MockProtocolSetupLabware).toHaveBeenCalled() }) it('should launch view only parameters screen when click parameters', () => { @@ -376,14 +466,14 @@ describe('ProtocolSetup', () => { expect(vi.mocked(ViewOnlyParameters)).toHaveBeenCalled() }) - it('should launch LPC when clicked', () => { - vi.mocked(useLPCDisabledReason).mockReturnValue(null) + it('should launch offsets screen when click offsets', () => { + MockProtocolSetupOffsets.mockImplementation( + vi.fn(() =>
Mock ProtocolSetupOffsets
) + ) render(`/runs/${RUN_ID}/setup/`) - screen.getByText(/Recommended/) - screen.getByText(/1 offset applied/) fireEvent.click(screen.getByText('Labware Position Check')) - expect(mockLaunchLPC).toHaveBeenCalled() - screen.getByText('mock LPC Wizard') + expect(MockProtocolSetupOffsets).toHaveBeenCalled() + screen.getByText(/Mock ProtocolSetupOffsets/) }) it('should render a confirmation modal when heater-shaker is in a protocol and it is not shaking', () => { @@ -416,7 +506,21 @@ describe('ProtocolSetup', () => { }) it('calls trackProtocolRunEvent when tapping play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByRole('button', { name: 'play' })) expect(mockTrackProtocolRunEvent).toBeCalledTimes(1) expect(mockTrackProtocolRunEvent).toHaveBeenCalledWith({ diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index 36ce4220bcb..f152b0cc44a 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -60,6 +60,7 @@ import { getProtocolModulesInfo } from '../../organisms/Devices/ProtocolRun/util import { ProtocolSetupLabware } from '../../organisms/ProtocolSetupLabware' import { ProtocolSetupModulesAndDeck } from '../../organisms/ProtocolSetupModulesAndDeck' import { ProtocolSetupLiquids } from '../../organisms/ProtocolSetupLiquids' +import { ProtocolSetupOffsets } from '../../organisms/ProtocolSetupOffsets' import { ProtocolSetupInstruments } from '../../organisms/ProtocolSetupInstruments' import { ProtocolSetupDeckConfiguration } from '../../organisms/ProtocolSetupDeckConfiguration' import { useLaunchLPC } from '../../organisms/LabwarePositionCheck/useLaunchLPC' @@ -85,6 +86,7 @@ import { } from '../../redux/analytics' import { getIsHeaterShakerAttached } from '../../redux/config' import { ConfirmAttachedModal } from './ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from './ConfirmSetupStepsCompleteModal' import { getLatestCurrentOffsets } from '../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' import { CloseButton, PlayButton } from './Buttons' import { useDeckConfigurationCompatibility } from '../../resources/deck_configuration/hooks' @@ -118,6 +120,8 @@ interface ProtocolSetupStepProps { subDetail?: string | null // disallow click handler, disabled styling disabled?: boolean + // disallow click handler, don't show CTA icons, allow styling + interactionDisabled?: boolean // display the reason the setup step is disabled disabledReason?: string | null // optional description @@ -137,12 +141,14 @@ export function ProtocolSetupStep({ detail, subDetail, disabled = false, + interactionDisabled = false, disabledReason, description, hasRightIcon = true, hasLeftIcon = true, fontSize = 'p', }: ProtocolSetupStepProps): JSX.Element { + const isInteractionDisabled = interactionDisabled || disabled const backgroundColorByStepStatus = { ready: COLORS.green35, 'not ready': COLORS.yellow35, @@ -185,9 +191,12 @@ export function ProtocolSetupStep({ return ( { - !disabled ? onClickSetupStep() : makeDisabledReasonSnackbar() + !isInteractionDisabled + ? onClickSetupStep() + : makeDisabledReasonSnackbar() }} width="100%" + data-testid={`SetupButton_${title}`} > {detail} @@ -249,7 +257,7 @@ export function ProtocolSetupStep({ {subDetail} - {disabled || !hasRightIcon ? null : ( + {interactionDisabled || !hasRightIcon ? null : ( > confirmAttachment: () => void + confirmStepsComplete: () => void play: () => void robotName: string runRecord: Run | null + labwareConfirmed: boolean + liquidsConfirmed: boolean + offsetsConfirmed: boolean } function PrepareToRun({ @@ -280,6 +292,10 @@ function PrepareToRun({ play, robotName, runRecord, + labwareConfirmed, + liquidsConfirmed, + offsetsConfirmed, + confirmStepsComplete, }: PrepareToRunProps): JSX.Element { const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const navigate = useNavigate() @@ -335,7 +351,6 @@ function PrepareToRun({ }, [mostRecentAnalysis?.status]) const robotType = useRobotType(robotName) - const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const onConfirmCancelClose = (): void => { setShowConfirmCancelModal(false) @@ -381,12 +396,7 @@ function PrepareToRun({ : null const isMissingModules = missingModuleIds.length > 0 - const lpcDisabledReason = useLPCDisabledReason({ - runId, - hasMissingModulesForOdd: isMissingModules, - hasMissingCalForOdd: - incompleteInstrumentCount != null && incompleteInstrumentCount > 0, - }) + const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] @@ -510,24 +520,25 @@ function PrepareToRun({ if (isDoorOpen) { makeSnackbar(t('shared:close_robot_door') as string) } else { - if ( - isHeaterShakerInProtocol && - isReadyToRun && - runStatus === RUN_STATUS_IDLE - ) { - confirmAttachment() - } else { - if (isReadyToRun) { + if (isReadyToRun) { + if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) { + confirmAttachment() + } else if ( + runStatus === RUN_STATUS_IDLE && + !(labwareConfirmed && offsetsConfirmed && liquidsConfirmed) + ) { + confirmStepsComplete() + } else { play() trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.START, properties: robotAnalyticsData ?? {}, }) - } else { - makeSnackbar( - i18n.format(t('complete_setup_before_proceeding'), 'capitalize') - ) } + } else { + makeSnackbar( + i18n.format(t('complete_setup_before_proceeding'), 'capitalize') + ) } } } @@ -752,22 +763,16 @@ function PrepareToRun({ /> { - launchLPC() + setSetupScreen('offsets') }} title={t('labware_position_check')} - detail={t( - lpcDisabledReason != null - ? 'currently_unavailable' - : 'recommended' - )} + detail={t('recommended')} subDetail={ latestCurrentOffsets.length > 0 ? t('offsets_applied', { count: latestCurrentOffsets.length }) : null } - status="general" - disabled={lpcDisabledReason != null} - disabledReason={lpcDisabledReason} + status={offsetsConfirmed ? 'ready' : 'general'} /> { @@ -776,25 +781,25 @@ function PrepareToRun({ title={t('parameters')} detail={parametersDetail} subDetail={null} - status="general" - disabled={!hasRunTimeParameters} + status="ready" + interactionDisabled={!hasRunTimeParameters} /> { setSetupScreen('labware') }} - title={t('labware')} + title={i18n.format(t('labware'), 'capitalize')} detail={labwareDetail} subDetail={labwareSubDetail} - status="general" + status={labwareConfirmed ? 'ready' : 'general'} disabled={labwareDetail == null} /> { setSetupScreen('liquids') }} - title={t('liquids')} - status="general" + title={i18n.format(t('liquids'), 'capitalize')} + status={liquidsConfirmed ? 'ready' : 'general'} detail={ liquidsInProtocol.length > 0 ? t('initial_liquids_num', { @@ -809,7 +814,6 @@ function PrepareToRun({ )}
- {LPCWizard} {showConfirmCancelModal ? ( () as OnDeviceRouteParams const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const { analysisErrors } = useProtocolAnalysisErrors(runId) + const { t } = useTranslation(['protocol_setup']) const localRobot = useSelector(getLocalRobot) + const robotName = localRobot?.name != null ? localRobot.name : 'no name' const robotSerialNumber = localRobot?.status != null ? getRobotSerialNumber(localRobot) : null const trackEvent = useTrackEvent() @@ -849,7 +856,69 @@ export function ProtocolSetup(): JSX.Element { showAnalysisFailedModal, setShowAnalysisFailedModal, ] = React.useState(true) + const robotType = useRobotType(robotName) + const attachedModules = + useAttachedModules({ + refetchInterval: FETCH_DURATION_MS, + }) ?? [] + const protocolId = runRecord?.data?.protocolId ?? null + const { data: protocolRecord } = useProtocolQuery(protocolId, { + staleTime: Infinity, + }) + const mostRecentAnalysisSummary = last(protocolRecord?.data.analysisSummaries) + const [ + isPollingForCompletedAnalysis, + setIsPollingForCompletedAnalysis, + ] = React.useState(mostRecentAnalysisSummary?.status !== 'completed') + const { + data: mostRecentAnalysis = null, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolRecord?.data.analysisSummaries)?.id ?? null, + { + enabled: protocolRecord != null && isPollingForCompletedAnalysis, + refetchInterval: ANALYSIS_POLL_MS, + } + ) + + React.useEffect(() => { + if (mostRecentAnalysis?.status === 'completed') { + setIsPollingForCompletedAnalysis(false) + } else { + setIsPollingForCompletedAnalysis(true) + } + }, [mostRecentAnalysis?.status]) + const deckDef = getDeckDefFromRobotType(robotType) + + const protocolModulesInfo = + mostRecentAnalysis != null + ? getProtocolModulesInfo(mostRecentAnalysis, deckDef) + : [] + + const { missingModuleIds } = getUnmatchedModulesForProtocol( + attachedModules, + protocolModulesInfo + ) + const isMissingModules = missingModuleIds.length > 0 + const { data: attachedInstruments } = useInstrumentsQuery() + + const incompleteInstrumentCount: number | null = + mostRecentAnalysis != null && attachedInstruments != null + ? getIncompleteInstrumentCount(mostRecentAnalysis, attachedInstruments) + : null + const lpcDisabledReason = useLPCDisabledReason({ + runId, + hasMissingModulesForOdd: isMissingModules, + hasMissingCalForOdd: + incompleteInstrumentCount != null && incompleteInstrumentCount > 0, + }) + const protocolName = + protocolRecord?.data.metadata.protocolName ?? + protocolRecord?.data.files[0].name ?? + '' + + const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const handleProceedToRunClick = (): void => { trackEvent({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, @@ -862,8 +931,8 @@ export function ProtocolSetup(): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation @@ -872,6 +941,22 @@ export function ProtocolSetup(): JSX.Element { const [providedFixtureOptions, setProvidedFixtureOptions] = React.useState< CutoutFixtureId[] >([]) + const [labwareConfirmed, setLabwareConfirmed] = React.useState(false) + const [liquidsConfirmed, setLiquidsConfirmed] = React.useState(false) + const [offsetsConfirmed, setOffsetsConfirmed] = React.useState(false) + const missingSteps = [ + !offsetsConfirmed ? t('applied_labware_offsets') : null, + !labwareConfirmed ? t('labware_placement') : null, + !liquidsConfirmed ? t('liquids') : null, + ].filter(s => s != null) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmation, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + !(labwareConfirmed && liquidsConfirmed && offsetsConfirmed) + ) // orchestrate setup subpages/components const [setupScreen, setSetupScreen] = React.useState( @@ -883,9 +968,13 @@ export function ProtocolSetup(): JSX.Element { runId={runId} setSetupScreen={setSetupScreen} confirmAttachment={confirmAttachment} + confirmStepsComplete={confirmMissingSteps} play={play} - robotName={localRobot?.name != null ? localRobot.name : 'no name'} + robotName={robotName} runRecord={runRecord ?? null} + labwareConfirmed={labwareConfirmed} + liquidsConfirmed={liquidsConfirmed} + offsetsConfirmed={offsetsConfirmed} /> ), instruments: ( @@ -899,11 +988,32 @@ export function ProtocolSetup(): JSX.Element { setProvidedFixtureOptions={setProvidedFixtureOptions} /> ), + offsets: ( + + ), labware: ( - + ), liquids: ( - + ), 'deck configuration': ( error.detail)} /> ) : null} - {showConfirmationModal ? ( + {showMissingStepsConfirmation ? ( + + ) : null} + {showHSConfirmationModal ? ( From 837d5ae458c66e5072deef02e30f9a9669231a6d Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 7 Aug 2024 09:53:50 -0400 Subject: [PATCH 17/39] feat(api): Allow omitting `description` and `display_color` from `ProtocolContext.define_liquid()` (#15906) --- api/docs/v2/versioning.rst | 5 ++ .../protocol_api/protocol_context.py | 53 +++++++++++++++++-- api/src/opentrons/protocol_api/validation.py | 2 +- .../protocol_api/test_protocol_context.py | 39 +++++++++++++- .../errors/exceptions.py | 2 +- 5 files changed, 95 insertions(+), 6 deletions(-) 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/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/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/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 888dc7f6763..e033ee144f7 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -964,7 +964,7 @@ def __init__( f"{api_element} is not yet available in the API version in use." ) if message: - checked_message = checked_message + message + checked_message = checked_message + " " + message checked_message = ( checked_message or "This feature is not yet available in the API version in use." From fe6252c268f51658e5de6e017cf6985e1f1f0e85 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Wed, 7 Aug 2024 10:43:35 -0400 Subject: [PATCH 18/39] fix(api): made a mistake in math when i removed the isclose check (#15913) # Overview Should have realized it was wrong when I had to change the test ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment Co-authored-by: caila-marashaj --- api/src/opentrons/hardware_control/ot3api.py | 7 +++++-- api/tests/opentrons/hardware_control/test_ot3_api.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 4f0cf262775..5f9c9840834 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2714,7 +2714,10 @@ async def liquid_probe( error: Optional[PipetteLiquidNotFoundError] = None pos = await self.gantry_position(checked_mount, refresh=True) - while (probe_start_pos.z - pos.z) < max_z_dist: + # probe_start_pos.z + z_distance of pass - pos.z should be < max_z_dist + # due to rounding errors this can get caught in an infinite loop when the distance is almost equal + # so we check to see if they're within 0.01 which is 1/5th the minimum movement distance from move_utils.py + while (probe_start_pos.z - pos.z) < (max_z_dist + 0.01): # safe distance so we don't accidentally aspirate liquid if we're already close to liquid safe_plunger_pos = top_types.Point( pos.x, pos.y, pos.z + probe_safe_reset_mm @@ -2724,7 +2727,7 @@ async def liquid_probe( pos.x, pos.y, pos.z + probe_pass_z_offset_mm ) max_z_time = ( - max_z_dist - (probe_start_pos.z - safe_plunger_pos.z) + max_z_dist - probe_start_pos.z + pass_start_pos.z ) / probe_settings.mount_speed p_travel_required_for_z = max_z_time * probe_settings.plunger_speed p_pass_travel = min(p_travel_required_for_z, p_working_mm) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 21ab1ad8ef9..190f8841c13 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -838,7 +838,7 @@ async def test_liquid_probe( mock_move_to_plunger_bottom.call_count == 2 mock_liquid_probe.assert_called_once_with( mount, - 52, + 46, fake_settings_aspirate.mount_speed, (fake_settings_aspirate.plunger_speed * -1), fake_settings_aspirate.sensor_threshold_pascals, @@ -990,7 +990,7 @@ async def _fake_pos_update_and_raise( OT3Mount.LEFT, fake_max_z_dist, fake_settings_aspirate ) # assert that it went through 4 passes and then prepared to aspirate - assert mock_move_to_plunger_bottom.call_count == 5 + assert mock_move_to_plunger_bottom.call_count == 4 @pytest.mark.parametrize( From a4811c19561d798e7339f0bf73cedf0782c2276f Mon Sep 17 00:00:00 2001 From: Caila Marashaj <98041399+caila-marashaj@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:45:10 -0400 Subject: [PATCH 19/39] feat(api): add tests for liquid probe movements (#15896) --- .../hardware_control/test_ot3_api.py | 181 +++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 190f8841c13..a6ae8e870d1 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_safe_reset_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], From 4693d04e36daec4fab9b4be1e8d77931eb661646 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 7 Aug 2024 11:14:19 -0400 Subject: [PATCH 20/39] refactor(app): Refactor intervention modal render behavior (#15898) Closes RQA-2904 The logic for rendering InterventionModal on the ODD/Desktop is a little bit different when looking at the exact conditions, and this (likely) causes the InterventionModal to render on the ODD sometimes but not on the desktop app, and vice versa. This is a good opportunity to refactor all of this logic into its own hook and use that hook where we render InterventionModal. After thinking through the render logic, there's room to simplify it a bit, too. We don't actually need stateful storage of an intervention command key. Also, I decided to separate showModal from modalProps (which lets us pass all the non-null props simply), even though we could technically just do a truthy check for modalProps for rendering InterventionModal, since this is maybe a bit more intuitive. Lastly, a few missing tests are added. To help with bug testing intervention modals, I added a couple console.warns. --- .../__tests__/InterventionModal.test.tsx | 67 ++++++- app/src/organisms/InterventionModal/index.tsx | 180 +++++++++++++----- .../__tests__/RunProgressMeter.test.tsx | 48 ++--- app/src/organisms/RunProgressMeter/index.tsx | 49 ++--- .../__tests__/RunningProtocol.test.tsx | 22 +++ app/src/pages/RunningProtocol/index.tsx | 52 ++--- 6 files changed, 262 insertions(+), 156 deletions(-) diff --git a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx index 06f4f0a22a3..e1a6830d251 100644 --- a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx +++ b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx @@ -1,11 +1,13 @@ import * as React from 'react' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, renderHook, screen } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { RUN_STATUS_RUNNING, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { getLabwareDefURI } from '@opentrons/shared-data' -import { renderWithProviders } from '../../../__testing-utils__' +import { renderWithProviders } from '../../../__testing-utils__' +import { mockTipRackDefinition } from '../../../redux/custom-labware/__fixtures__' import { i18n } from '../../../i18n' -import { InterventionModal } from '..' import { mockPauseCommandWithoutStartTime, mockPauseCommandWithStartTime, @@ -13,9 +15,11 @@ import { mockMoveLabwareCommandFromModule, truncatedCommandMessage, } from '../__fixtures__' -import { mockTipRackDefinition } from '../../../redux/custom-labware/__fixtures__' +import { InterventionModal, useInterventionModal } from '..' import { useIsFlex } from '../../Devices/hooks' + import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import type { RunData } from '@opentrons/api-client' const ROBOT_NAME = 'Otie' @@ -23,6 +27,61 @@ const mockOnResumeHandler = vi.fn() vi.mock('../../Devices/hooks') +describe('useInterventionModal', () => { + const defaultProps = { + runData: { id: 'run1' } as RunData, + lastRunCommand: mockPauseCommandWithStartTime, + runStatus: RUN_STATUS_RUNNING, + robotName: 'TestRobot', + analysis: null, + } + + it('should return showModal true when conditions are met', () => { + const { result } = renderHook(() => useInterventionModal(defaultProps)) + + expect(result.current.showModal).toBe(true) + expect(result.current.modalProps).not.toBeNull() + }) + + it('should return showModal false when runStatus is terminal', () => { + const props = { ...defaultProps, runStatus: RUN_STATUS_STOPPED } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return showModal false when lastRunCommand is null', () => { + const props = { ...defaultProps, lastRunCommand: null } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return showModal false when robotName is null', () => { + const props = { ...defaultProps, robotName: null } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return correct modalProps when showModal is true', () => { + const { result } = renderHook(() => useInterventionModal(defaultProps)) + + expect(result.current.modalProps).toEqual({ + command: mockPauseCommandWithStartTime, + run: defaultProps.runData, + robotName: 'TestRobot', + analysis: null, + }) + }) +}) + const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, diff --git a/app/src/organisms/InterventionModal/index.tsx b/app/src/organisms/InterventionModal/index.tsx index 1e7cb9e8475..6c3002ce26b 100644 --- a/app/src/organisms/InterventionModal/index.tsx +++ b/app/src/organisms/InterventionModal/index.tsx @@ -19,6 +19,12 @@ import { TYPOGRAPHY, LegacyStyledText, } from '@opentrons/components' +import { + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, +} from '@opentrons/api-client' import { SmallButton } from '../../atoms/buttons' import { Modal } from '../../molecules/Modal' @@ -26,30 +32,66 @@ import { InterventionModal as InterventionModalMolecule } from '../../molecules/ import { getIsOnDevice } from '../../redux/config' import { PauseInterventionContent } from './PauseInterventionContent' import { MoveLabwareInterventionContent } from './MoveLabwareInterventionContent' +import { isInterventionCommand } from './utils' +import { useRobotType } from '../Devices/hooks' -import type { RunCommandSummary, RunData } from '@opentrons/api-client' import type { IconName } from '@opentrons/components' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -import { useRobotType } from '../Devices/hooks' +import type { + RunCommandSummary, + RunData, + RunStatus, +} from '@opentrons/api-client' -const LEARN_ABOUT_MANUAL_STEPS_URL = - 'https://support.opentrons.com/s/article/Manual-protocol-steps' +const TERMINAL_RUN_STATUSES: RunStatus[] = [ + RUN_STATUS_STOPPED, + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_SUCCEEDED, +] -const CONTENT_STYLE = { - display: DISPLAY_FLEX, - flexDirection: DIRECTION_COLUMN, - alignItems: ALIGN_FLEX_START, - gridGap: SPACING.spacing24, - padding: SPACING.spacing32, - borderRadius: BORDERS.borderRadius8, -} as const +export interface UseInterventionModalProps { + runData: RunData | null + lastRunCommand: RunCommandSummary | null + runStatus: RunStatus | null + robotName: string | null + analysis: CompletedProtocolAnalysis | null +} -const FOOTER_STYLE = { - display: DISPLAY_FLEX, - width: '100%', - alignItems: ALIGN_CENTER, - justifyContent: JUSTIFY_SPACE_BETWEEN, -} as const +export type UseInterventionModalResult = + | { showModal: false; modalProps: null } + | { showModal: true; modalProps: Omit } + +// If showModal is true, modalProps are guaranteed not to be null. +export function useInterventionModal({ + runData, + lastRunCommand, + runStatus, + robotName, + analysis, +}: UseInterventionModalProps): UseInterventionModalResult { + const isValidIntervention = + lastRunCommand != null && + robotName != null && + isInterventionCommand(lastRunCommand) && + runData != null && + runStatus != null && + !TERMINAL_RUN_STATUSES.includes(runStatus) + + if (!isValidIntervention) { + return { showModal: false, modalProps: null } + } else { + return { + showModal: true, + modalProps: { + command: lastRunCommand, + run: runData, + robotName, + analysis, + }, + } + } +} export interface InterventionModalProps { robotName: string @@ -71,25 +113,28 @@ export function InterventionModal({ const robotType = useRobotType(robotName) const childContent = React.useMemo(() => { - if ( - command.commandType === 'waitForResume' || - command.commandType === 'pause' // legacy pause command - ) { - return ( - - ) - } else if (command.commandType === 'moveLabware') { - return ( - - ) - } else { - return null + switch (command.commandType) { + case 'waitForResume': + case 'pause': // legacy pause command + return ( + + ) + case 'moveLabware': + return ( + + ) + default: + console.warn( + 'Unhandled command passed to InterventionModal: ', + command.commandType + ) + return null } }, [ command.id, @@ -98,21 +143,33 @@ export function InterventionModal({ run.modules.map(m => m.id).join(), ]) - let iconName: IconName | null = null - let headerTitle = '' - let headerTitleOnDevice = '' - if ( - command.commandType === 'waitForResume' || - command.commandType === 'pause' // legacy pause command - ) { - iconName = 'pause-circle' - headerTitle = t('pause_on', { robot_name: robotName }) - headerTitleOnDevice = t('pause') - } else if (command.commandType === 'moveLabware') { - iconName = 'move-xy-circle' - headerTitle = t('move_labware_on', { robot_name: robotName }) - headerTitleOnDevice = t('move_labware') - } + const { iconName, headerTitle, headerTitleOnDevice } = (() => { + switch (command.commandType) { + case 'waitForResume': + case 'pause': + return { + iconName: 'pause-circle' as IconName, + headerTitle: t('pause_on', { robot_name: robotName }), + headerTitleOnDevice: t('pause'), + } + case 'moveLabware': + return { + iconName: 'move-xy-circle' as IconName, + headerTitle: t('move_labware_on', { robot_name: robotName }), + headerTitleOnDevice: t('move_labware'), + } + default: + console.warn( + 'Unhandled command passed to InterventionModal: ', + command.commandType + ) + return { + iconName: null, + headerTitle: '', + headerTitleOnDevice: '', + } + } + })() // TODO(bh, 2023-7-18): this is a one-off modal implementation for desktop // reimplement when design system shares a modal component between desktop/ODD @@ -171,3 +228,22 @@ export function InterventionModal({ ) } + +const LEARN_ABOUT_MANUAL_STEPS_URL = + 'https://support.opentrons.com/s/article/Manual-protocol-steps' + +const CONTENT_STYLE = { + display: DISPLAY_FLEX, + flexDirection: DIRECTION_COLUMN, + alignItems: ALIGN_FLEX_START, + gridGap: SPACING.spacing24, + padding: SPACING.spacing32, + borderRadius: BORDERS.borderRadius8, +} as const + +const FOOTER_STYLE = { + display: DISPLAY_FLEX, + width: '100%', + alignItems: ALIGN_CENTER, + justifyContent: JUSTIFY_SPACE_BETWEEN, +} as const diff --git a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 0cab1ef5adb..10ecdb7bf9e 100644 --- a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -13,7 +13,10 @@ import { } from '@opentrons/api-client' import { i18n } from '../../../i18n' -import { InterventionModal } from '../../InterventionModal' +import { + useInterventionModal, + InterventionModal, +} from '../../InterventionModal' import { ProgressBar } from '../../../atoms/ProgressBar' import { useRunStatus } from '../../RunTimeControl/hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -27,11 +30,7 @@ import { mockUseCommandResultNonDeterministic, NON_DETERMINISTIC_COMMAND_KEY, } from '../__fixtures__' -import { - mockMoveLabwareCommandFromSlot, - mockPauseCommandWithStartTime, - mockRunData, -} from '../../InterventionModal/__fixtures__' + import { RunProgressMeter } from '..' import { renderWithProviders } from '../../../__testing-utils__' import { useLastRunCommand } from '../../Devices/hooks/useLastRunCommand' @@ -70,7 +69,7 @@ describe('RunProgressMeter', () => { beforeEach(() => { vi.mocked(ProgressBar).mockReturnValue(
MOCK PROGRESS BAR
) vi.mocked(InterventionModal).mockReturnValue( -
MOCK INTERVENTION MODAL
+
MOCK_INTERVENTION_MODAL
) vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) when(useMostRecentCompletedAnalysis) @@ -96,6 +95,10 @@ describe('RunProgressMeter', () => { currentStepNumber: null, hasRunDiverged: true, }) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: false, + modalProps: {} as any, + }) props = { runId: NON_DETERMINISTIC_RUN_ID, @@ -119,31 +122,18 @@ describe('RunProgressMeter', () => { screen.getByText('Not started yet') screen.getByText('Download run log') }) - it('should render an intervention modal when lastRunCommand is a pause command', () => { - vi.mocked(useNotifyAllCommandsQuery).mockReturnValue({ - data: { data: [mockPauseCommandWithStartTime], meta: { totalLength: 1 } }, - } as any) - vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: { labware: [] } }, - } as any) - vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({} as any) - render(props) - }) - it('should render an intervention modal when lastRunCommand is a move labware command', () => { - vi.mocked(useNotifyAllCommandsQuery).mockReturnValue({ - data: { - data: [mockMoveLabwareCommandFromSlot], - meta: { totalLength: 1 }, - }, - } as any) - vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: mockRunData }, - } as any) + it('should render an intervention modal when showInterventionModal is true', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({} as any) + vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: true, + modalProps: {} as any, + }) + render(props) + + screen.getByText('MOCK_INTERVENTION_MODAL') }) it('should render the correct run status when run status is completed', () => { diff --git a/app/src/organisms/RunProgressMeter/index.tsx b/app/src/organisms/RunProgressMeter/index.tsx index 5f120904a41..eecf73a96f9 100644 --- a/app/src/organisms/RunProgressMeter/index.tsx +++ b/app/src/organisms/RunProgressMeter/index.tsx @@ -30,17 +30,15 @@ import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostR import { getModalPortalEl } from '../../App/portal' import { Tooltip } from '../../atoms/Tooltip' import { useRunStatus } from '../RunTimeControl/hooks' -import { InterventionModal } from '../InterventionModal' +import { InterventionModal, useInterventionModal } from '../InterventionModal' import { ProgressBar } from '../../atoms/ProgressBar' import { useDownloadRunLog, useRobotType } from '../Devices/hooks' import { InterventionTicks } from './InterventionTicks' -import { isInterventionCommand } from '../InterventionModal/utils' import { useNotifyRunQuery, useNotifyAllCommandsQuery, } from '../../resources/runs' import { useRunningStepCounts } from '../../resources/protocols/hooks' -import { TERMINAL_RUN_STATUSES } from './constants' import { useRunProgressCopy } from './hooks' interface RunProgressMeterProps { @@ -51,10 +49,6 @@ interface RunProgressMeterProps { } export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { runId, robotName, makeHandleJumpToStep, resumeRunHandler } = props - const [ - interventionModalCommandKey, - setInterventionModalCommandKey, - ] = React.useState(null) const { t } = useTranslation('run_details') const robotType = useRobotType(robotName) const runStatus = useRunStatus(runId) @@ -91,23 +85,6 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { downloadRunLog } = useDownloadRunLog(robotName, runId) - React.useEffect(() => { - if ( - lastRunCommand != null && - interventionModalCommandKey != null && - lastRunCommand.key !== interventionModalCommandKey - ) { - // set intervention modal command key to null if different from current command key - setInterventionModalCommandKey(null) - } else if ( - lastRunCommand?.key != null && - isInterventionCommand(lastRunCommand) && - interventionModalCommandKey === null - ) { - setInterventionModalCommandKey(lastRunCommand.key) - } - }, [lastRunCommand, interventionModalCommandKey]) - const onDownloadClick: React.MouseEventHandler = e => { if (downloadIsDisabled) return false e.preventDefault() @@ -115,6 +92,17 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { downloadRunLog() } + const { + showModal: showIntervention, + modalProps: interventionProps, + } = useInterventionModal({ + robotName, + runStatus, + runData, + analysis, + lastRunCommand, + }) + const { progressPercentage, stepCountStr, @@ -132,20 +120,11 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { return ( <> - {interventionModalCommandKey != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) && - analysisCommands != null && - runStatus != null && - runData != null && - !TERMINAL_RUN_STATUSES.includes(runStatus) + {showIntervention ? createPortal( , getModalPortalEl() ) diff --git a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx index 1114f4964eb..bddb00263d4 100644 --- a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -44,6 +44,10 @@ import { useErrorRecoveryFlows, } from '../../../organisms/ErrorRecoveryFlows' import { useLastRunCommand } from '../../../organisms/Devices/hooks/useLastRunCommand' +import { + useInterventionModal, + InterventionModal, +} from '../../../organisms/InterventionModal' import type { UseQueryResult } from 'react-query' import type { ProtocolAnalyses, RunCommandSummary } from '@opentrons/api-client' @@ -64,6 +68,7 @@ vi.mock('../../../resources/runs') vi.mock('../../../redux/config') vi.mock('../../../organisms/ErrorRecoveryFlows') vi.mock('../../../organisms/Devices/hooks/useLastRunCommand') +vi.mock('../../../organisms/InterventionModal') const RUN_ID = 'run_id' const ROBOT_NAME = 'otie' @@ -159,6 +164,13 @@ describe('RunningProtocol', () => { isERActive: false, failedCommand: {} as any, }) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: false, + modalProps: {} as any, + }) + vi.mocked(InterventionModal).mockReturnValue( +
MOCK_INTERVENTION_MODAL
+ ) }) afterEach(() => { @@ -219,6 +231,16 @@ describe('RunningProtocol', () => { screen.getByText('MOCK ERROR RECOVERY') }) + it('should render an InterventionModal appropriately', () => { + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: true, + modalProps: {} as any, + }) + render(`/runs/${RUN_ID}/run`) + + screen.getByText('MOCK_INTERVENTION_MODAL') + }) + // ToDo (kj:04/04/2023) need to figure out the way to simulate swipe it.todo('should render RunningProtocolCommandList when swiping left') // const [{ getByText }] = render(`/runs/${RUN_ID}/run`) diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index df35c6fa846..f1ca179167d 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -24,14 +24,15 @@ import { import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FINISHING, } from '@opentrons/api-client' import { StepMeter } from '../../atoms/StepMeter' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useNotifyRunQuery } from '../../resources/runs' -import { InterventionModal } from '../../organisms/InterventionModal' -import { isInterventionCommand } from '../../organisms/InterventionModal/utils' +import { + InterventionModal, + useInterventionModal, +} from '../../organisms/InterventionModal' import { useRunStatus, useRunTimestamps, @@ -89,10 +90,6 @@ export function RunningProtocol(): JSX.Element { showConfirmCancelRunModal, setShowConfirmCancelRunModal, ] = React.useState(false) - const [ - interventionModalCommandKey, - setInterventionModalCommandKey, - ] = React.useState(null) const lastAnimatedCommand = React.useRef(null) const { ref, style, swipeType, setSwipeType } = useSwipe() const robotSideAnalysis = useMostRecentCompletedAnalysis(runId) @@ -124,6 +121,16 @@ export function RunningProtocol(): JSX.Element { const robotAnalyticsData = useRobotAnalyticsData(robotName) const robotType = useRobotType(robotName) const { isERActive, failedCommand } = useErrorRecoveryFlows(runId, runStatus) + const { + showModal: showIntervention, + modalProps: interventionProps, + } = useInterventionModal({ + runStatus, + lastRunCommand, + runData: runRecord?.data ?? null, + robotName, + analysis: robotSideAnalysis, + }) React.useEffect(() => { if ( @@ -143,23 +150,6 @@ export function RunningProtocol(): JSX.Element { } }, [currentOption, swipeType, setSwipeType]) - React.useEffect(() => { - if ( - lastRunCommand != null && - interventionModalCommandKey != null && - lastRunCommand.key !== interventionModalCommandKey - ) { - // set intervention modal command key to null if different from current command key - setInterventionModalCommandKey(null) - } else if ( - lastRunCommand?.key != null && - isInterventionCommand(lastRunCommand) && - interventionModalCommandKey === null - ) { - setInterventionModalCommandKey(lastRunCommand.key) - } - }, [lastRunCommand, interventionModalCommandKey]) - return ( <> {isERActive ? ( @@ -202,18 +192,8 @@ export function RunningProtocol(): JSX.Element { isActiveRun={true} /> ) : null} - {interventionModalCommandKey != null && - runRecord?.data != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) && - runStatus !== RUN_STATUS_FINISHING ? ( - + {showIntervention ? ( + ) : null} Date: Wed, 7 Aug 2024 11:37:44 -0400 Subject: [PATCH 21/39] feat(app, components): add modal for stacked entities (#15895) To give clarity to the contents of labware/adapter/module stacks, here, I add a modal when clicking a stack on Labware setup deck map (for both Desktop and ODD). Each element of the stack will be highlighted described in a list item containing the element's name, optional nickname, and isometric SVG or PNG representation depending on its type. Closes [PLAT-376](https://opentrons.atlassian.net/browse/PLAT-376), [PLAT-378](https://opentrons.atlassian.net/browse/PLAT-378) --- .../opentrons_flex_96_tiprack_adapter.png | Bin 0 -> 155090 bytes .../localization/en/protocol_setup.json | 1 + app/src/molecules/Modal/types.ts | 2 +- .../SetupLabware/LabwareStackModal.tsx | 252 ++++++++++++++++++ .../SetupLabware/SetupLabwareMap.tsx | 27 +- .../SetupLiquids/SetupLiquidsMap.tsx | 7 +- .../__tests__/getLocationInfoNames.test.ts | 2 + .../ProtocolRun/utils/getLocationInfoNames.ts | 7 + .../__tests__/ProtocolSetupLabware.test.tsx | 2 + .../organisms/ProtocolSetupLabware/index.tsx | 51 +++- .../hardware-sim/Labware/LabwareRender.tsx | 1 + .../Labware/LabwareStackRender.tsx | 211 ++++++++++----- .../labwareInternals/LabwareOutline.tsx | 16 +- .../labwareInternals/StaticLabware.tsx | 3 + 14 files changed, 503 insertions(+), 79 deletions(-) create mode 100644 app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png create mode 100644 app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx 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 0000000000000000000000000000000000000000..28a65ff766b0c882f5ba5c198cb61986b9976bb8 GIT binary patch literal 155090 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%0D-O+=p?}&!o;x@}Zply?m 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 R0RjL3002ovPDHLkV1nH+4mSV* literal 0 HcmV?d00001 diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 2eef6a6c15c..2a21ab64da1 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -273,6 +273,7 @@ "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}}", diff --git a/app/src/molecules/Modal/types.ts b/app/src/molecules/Modal/types.ts index a9ddd05ffed..ae9758b1cdd 100644 --- a/app/src/molecules/Modal/types.ts +++ b/app/src/molecules/Modal/types.ts @@ -3,7 +3,7 @@ import type { IconName, StyleProps } from '@opentrons/components' export type ModalSize = 'small' | 'medium' | 'large' export interface ModalHeaderBaseProps extends StyleProps { - title: string + title: string | JSX.Element onClick?: React.MouseEventHandler hasExitIcon?: boolean iconName?: IconName 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..2c89240bf6f --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -0,0 +1,252 @@ +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, +} from '@opentrons/components' +import { Modal } from '../../../../molecules/Modal' +import { getIsOnDevice } from '../../../../redux/config' +import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { LegacyModal } from '../../../../molecules/LegacyModal' +import { getLocationInfoNames } from '../utils/getLocationInfoNames' +import { getSlotLabwareDefinition } from '../utils/getSlotLabwareDefinition' +import { Divider } from '../../../../atoms/structure' +import { getModuleImage } from '../SetupModuleAndDeck/utils' +import { getModuleDisplayName } from '@opentrons/shared-data' +import tiprackAdapter from '../../../../assets/images/labware/opentrons_flex_96_tiprack_adapter.png' + +const HIDE_SCROLLBAR = css` + ::-webkit-scrollbar { + display: none; + } +` + +interface LabwareStackModalProps { + labwareIdTop: string + runId: string + closeModal: () => void +} + +export const LabwareStackModal = ( + props: LabwareStackModalProps +): JSX.Element | null => { + const { labwareIdTop, runId, closeModal } = 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 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} + + + ) : ( + + + + {t('stacked_slot')} +
+ } + childrenPadding={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..ae8f3bbea02 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,11 @@ export function SetupLabwareMap({ protocolAnalysis, }: SetupLabwareMapProps): JSX.Element | null { // early return null if no protocol analysis + const [ + labwareStackDetailsLabwareId, + setLabwareStackDetailsLabwareId, + ] = React.useState(null) + if (protocolAnalysis == null) return null const commands = protocolAnalysis.commands @@ -76,7 +82,15 @@ export function SetupLabwareMap({ nestedLabwareDef: topLabwareDefinition, moduleChildren: ( - <> + // open modal + { + if (topLabwareDefinition != null) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + cursor="pointer" + > {topLabwareDefinition != null && topLabwareId != null ? ( ) : null} - + ), } }) @@ -143,6 +157,15 @@ export function SetupLabwareMap({ commands={commands} />
+ {labwareStackDetailsLabwareId != null && ( + { + setLabwareStackDetailsLabwareId(null) + }} + /> + )}
) } 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/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/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 0edc5a1ad1a..99b3c555dd5 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -123,6 +123,8 @@ describe('ProtocolSetupLabware', () => { expect(screen.queryByText('Map View')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'List View' })) screen.getByText('Labware') + screen.getByText('Labware name') + screen.getByText('Location') }) it('sends a latch-close command when the labware latch is open and the button is clicked', () => { diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 1210c1887df..919d887f491 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -6,7 +6,6 @@ import styled, { css } from 'styled-components' import { ALIGN_CENTER, ALIGN_FLEX_START, - ALIGN_STRETCH, BORDERS, Box, COLORS, @@ -50,6 +49,7 @@ import { getProtocolModulesInfo } from '../Devices/ProtocolRun/utils/getProtocol import { getAttachedProtocolModuleMatches } from '../ProtocolSetupModulesAndDeck/utils' import { getNestedLabwareInfo } from '../Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo' import { LabwareMapView } from './LabwareMapView' +import { LabwareStackModal } from '../Devices/ProtocolRun/SetupLabware/LabwareStackModal' import type { UseQueryResult } from 'react-query' import type { @@ -149,6 +149,7 @@ export function ProtocolSetupLabware({ } let location: JSX.Element | string | null = null + let topLabwareId: string | null = null if ( selectedLabware != null && typeof selectedLabware.location === 'object' && @@ -177,6 +178,17 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { + topLabwareId = + mostRecentAnalysis?.commands.find( + (command): command is LoadLabwareRunTimeCommand => { + return ( + command.commandType === 'loadLabware' && + typeof command.params.location === 'object' && + 'moduleId' in command.params.location && + command.params.location.moduleId === matchedModule.moduleId + ) + } + )?.result?.labwareId ?? null location = } } else if ( @@ -191,6 +203,17 @@ export function ProtocolSetupLabware({ command.result?.labwareId === adapterId )?.params.location if (adapterLocation != null && adapterLocation !== 'offDeck') { + topLabwareId = + mostRecentAnalysis?.commands.find( + (command): command is LoadLabwareRunTimeCommand => { + return ( + command.commandType === 'loadLabware' && + typeof command.params.location === 'object' && + 'labwareId' in command.params.location && + command.params.location.labwareId === adapterId + ) + } + )?.result?.labwareId ?? null if ('slotName' in adapterLocation) { location = } else if ('moduleId' in adapterLocation) { @@ -208,19 +231,16 @@ export function ProtocolSetupLabware({ <> {createPortal( <> - {showLabwareDetailsModal && selectedLabware != null ? ( + {showLabwareDetailsModal && + topLabwareId == null && + selectedLabware != null ? ( { setShowLabwareDetailsModal(false) setSelectedLabware(null) }} > - - - - + + + + ) : null} @@ -332,6 +357,16 @@ export function ProtocolSetupLabware({ })} )} + {showLabwareDetailsModal && topLabwareId != null ? ( + { + setSelectedLabware(null) + setShowLabwareDetailsModal(false) + }} + /> + ) : null} { : `translate(${cornerOffsetFromSlot.x}, ${cornerOffsetFromSlot.y})` } ref={gRef} + onClick={props.onLabwareClick} > { + const { xDimension, yDimension } = definition.dimensions + return Math.round( + xDimension * Math.sin(SKEW_ANGLE_RADIANS) + + (yDimension / Math.cos(SKEW_ANGLE_RADIANS)) * + Math.cos(SKEW_ANGLE_RADIANS + ROTATE_ANGLE_RADIANS) + ) +} + +const getLabwareHeightIso = (definition: LabwareDefinition2): number => { + const { zDimension } = definition.dimensions + return Math.round(getLabwareFaceHeightIso(definition) + zDimension) +} + +const getXMinForViewbox = (definition: LabwareDefinition2): number => { + const { yDimension } = definition.dimensions + return Math.round( + (yDimension / Math.cos(SKEW_ANGLE_RADIANS)) * + Math.cos(Math.PI / 2 - (SKEW_ANGLE_RADIANS + ROTATE_ANGLE_RADIANS)) + ) +} + +const getLabwareWidthIso = (definition: LabwareDefinition2): number => { + const { xDimension } = definition.dimensions + return ( + getXMinForViewbox(definition) + xDimension * Math.cos(ROTATE_ANGLE_RADIANS) + ) +} + export const LabwareStackRender = ( props: LabwareStackRenderProps ): JSX.Element => { @@ -48,38 +82,58 @@ export const LabwareStackRender = ( const fillColorBottom = highlightBottom ? HIGHLIGHT_COLOR : COLORS.white // only one labware (top) - if (definitionBottom == null) { + if ( + definitionBottom == null || + definitionBottom.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' + ) { const { xDimension, yDimension } = definitionTop.dimensions const isTopAdapter = definitionTop.metadata.displayCategory === 'adapter' return isTopAdapter ? ( // adapter render - - - - - + + ) : ( // isometric view of labware - + - - {wellLabelOption != null ? ( + + {wellLabelOption != null && + definitionTop.metadata.displayCategory !== 'adapter' ? ( ) : null} @@ -87,7 +141,7 @@ export const LabwareStackRender = ( - + ) } + const xMinForViewbox = Math.min( + ...[definitionTop, definitionBottom].map(def => getXMinForViewbox(def)) + ) + + const totalAssemblyHeight = + getLabwareHeightIso(definitionTop) + + STACK_SEPARATION_MM + + definitionBottom.dimensions.zDimension + return ( - + {/* bottom labware/adapter */} - - - {wellLabelOption != null && - definitionTop.metadata.displayCategory !== 'adapter' ? ( - + + + {wellLabelOption != null && + definitionBottom.metadata.displayCategory !== 'adapter' ? ( + + ) : null} + + - ) : null} - - - + + + )} {/* top labware/adapter */} - + {wellLabelOption != null && definitionTop.metadata.displayCategory !== 'adapter' ? ( - + ) } diff --git a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx index 9eda65289ec..7478c671114 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx @@ -19,6 +19,7 @@ export interface LabwareOutlineProps { /** [legacy] override the border color */ stroke?: CSSProperties['stroke'] fill?: CSSProperties['fill'] + showRadius?: boolean } const OUTLINE_THICKNESS_MM = 1 @@ -32,6 +33,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { highlight = false, stroke, fill, + showRadius = true, } = props const { parameters = { isTiprack }, @@ -62,6 +64,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { stroke="#74B0FF" rx="8" ry="8" + showRadius={showRadius} /> ) : ( @@ -80,6 +84,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { yDimension={dimensions.yDimension} stroke={stroke ?? (parameters.isTiprack ? '#979797' : COLORS.black90)} fill={backgroundFill} + showRadius={showRadius} /> )} @@ -90,9 +95,16 @@ interface LabwareBorderProps extends React.SVGProps { borderThickness: number xDimension: number yDimension: number + showRadius?: boolean } function LabwareBorder(props: LabwareBorderProps): JSX.Element { - const { borderThickness, xDimension, yDimension, ...svgProps } = props + const { + borderThickness, + xDimension, + yDimension, + showRadius = true, + ...svgProps + } = props return ( ) diff --git a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx index c8341c94a07..4094ea1e038 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx @@ -24,6 +24,7 @@ export interface StaticLabwareProps { /** Optional callback to be executed when mouse leaves a well element */ onMouseLeaveWell?: (e: WellMouseEvent) => unknown fill?: CSSProperties['fill'] + showRadius?: boolean } const TipDecoration = React.memo(function TipDecoration(props: { @@ -58,6 +59,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { onMouseEnterWell, onMouseLeaveWell, fill, + showRadius = true, } = props const { isTiprack } = definition.parameters @@ -68,6 +70,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { definition={definition} highlight={highlight} fill={fill} + showRadius={showRadius} /> From 45344f591d3ffab015e693eecb703c676227e513 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:08:52 -0400 Subject: [PATCH 22/39] fix(app): fix accessing file name on FileCard (#15917) Fixes accidental reversion resulting in populating file name on `FileCard` with empty string. We need to reach into `csvParam.file.file.name` rather than just `csvParam.file.name` on desktop. --- app/src/organisms/ChooseRobotSlideout/FileCard.tsx | 2 +- .../organisms/ChooseRobotSlideout/__tests__/FileCard.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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)} Date: Wed, 7 Aug 2024 13:09:27 -0400 Subject: [PATCH 23/39] feat(app): update LegacyModalHeader for new designs (#15914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview Update `LegacyModalHeader` according to new designs to accept up to 2 elements ahead of title text. Add props to `LegacyModal` that are passed down to header, and implement at `LabwareStackRender` Screenshot 2024-08-07 at 11 57 09 AM Closes PLAT-395 --- .../LegacyModal/LegacyModalHeader.tsx | 20 ++++++++++++++++--- app/src/molecules/LegacyModal/index.tsx | 6 ++++++ .../SetupLabware/LabwareStackModal.tsx | 1 + 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/molecules/LegacyModal/LegacyModalHeader.tsx b/app/src/molecules/LegacyModal/LegacyModalHeader.tsx index 725df7c0c4b..8c884deef80 100644 --- a/app/src/molecules/LegacyModal/LegacyModalHeader.tsx +++ b/app/src/molecules/LegacyModal/LegacyModalHeader.tsx @@ -8,8 +8,8 @@ import { Icon, JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, - SPACING, LegacyStyledText, + SPACING, TYPOGRAPHY, } from '@opentrons/components' @@ -19,6 +19,8 @@ import type { IconProps } from '@opentrons/components' export interface LegacyModalHeaderProps { onClose?: React.MouseEventHandler title: React.ReactNode + titleElement1?: JSX.Element + titleElement2?: JSX.Element backgroundColor?: string color?: string icon?: IconProps @@ -44,7 +46,16 @@ const closeIconStyles = css` export const LegacyModalHeader = ( props: LegacyModalHeaderProps ): JSX.Element => { - const { icon, onClose, title, backgroundColor, color, closeButton } = props + const { + icon, + onClose, + title, + titleElement1, + titleElement2, + backgroundColor, + color, + closeButton, + } = props return ( <> - + {icon != null && } + {titleElement1} + {titleElement2} + {/* TODO (nd: 08/07/2024) Convert to StyledText once designs are resolved */} { childrenPadding = `${SPACING.spacing16} ${SPACING.spacing24} ${SPACING.spacing24}`, children, footer, + titleElement1, + titleElement2, ...styleProps } = props @@ -58,6 +62,8 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { } childrenPadding={0} + marginLeft="0" > From 927d36802bbedcbe72b8e891516059585870a6fb Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:39:34 -0400 Subject: [PATCH 24/39] feat(app): extend `getModuleImage` util for high res images (#15920) Adds optional `highRes` argument to `getModuleImage` util to retrieve higher res module images where applicable for use in `LabwareStackModal`. --- .../SetupLabware/LabwareStackModal.tsx | 6 +++- .../__tests__/utils.test.ts | 28 +++++++++++++++++++ .../ProtocolRun/SetupModuleAndDeck/utils.ts | 17 +++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx index 601cbc1d091..b65a8b38eb4 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -67,7 +67,11 @@ export const LabwareStackModal = ( ) const moduleImg = moduleModel != null ? ( - + ) : null return isOnDevice ? ( 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': From 90127ff5143f1236659d182ceb8c02080c3e5cc6 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 7 Aug 2024 14:18:53 -0400 Subject: [PATCH 25/39] fix(robot-server): fetching of data files used in runs of a protocol (#15908) Closes AUTH-638 # Overview Fixes a bug where CSV files used in protocol runs were not being saved to the CSV RTP table for runs, and as a result, were not being included in response for the `/protocols/{protocolId}/dataFiles` endpoint. ## Test Plan and Hands on Testing Testing with app & ODD: - [x] Upload a protocol that uses a CSV parameter - [x] Send the protocol to a robot, upload the csv file to use - [x] Run the protocol - [x] Run the protocol again with a different CSV file -> Do this 5 times (so total 6 runs with 6 different CSV files). By re-running the protocol 6 times, we are making the robot delete its oldest analysis (since max analyses per protocol is 5), essentially deleting the first CSV file from the *analysis* csv table, but not from runs table - [x] Check that when you run the protocol again on the ODD, it shows you all the 6 different CSV files previously uploaded Testing with Postman/ direct HTTP requests: - [x] Upload a few data files - [x] Upload a protocol that uses a CSV parameter and specify a data file (data_file_1) for the CSV param - [x] Start a new analysis for the same protocol by specifying a second data file (data_file_2) for the CSV param - [x] Create a run for the protocol by specifying data_file_1 for its CSV param - [x] Create another run for the protocol by specifying a third data file (data_file_3) for its CSV param - [x] Check that the response to `GET /protocols/{protocolId}/dataFiles` contains the 3 data files used with the runs & analyses. Check that they are listed in the order that the files were uploaded to the server (via `POST /dataFiles`) ## Changelog - wired up CSV RTP table insertion during run creation - updated the run deletion code to remove the CSV RTP entry from the `run_csv_rtp_table` before deleting the run. - updated the `../{protocolId}/dataFiles` response so that it lists the files in the order they were uploaded. - added tests ## Risk assessment Low. Fixes bug --- .../robot_server/protocols/protocol_store.py | 37 ++- .../robot_server/runs/run_data_manager.py | 6 +- robot-server/robot_server/runs/run_store.py | 8 +- ...lyses_with_csv_file_parameters.tavern.yaml | 1 + ...t_csv_files_used_with_protocol.tavern.yaml | 250 ++++++++++++++++++ ...t_run_with_run_time_parameters.tavern.yaml | 4 +- .../tests/protocols/test_protocol_store.py | 41 ++- .../tests/runs/test_run_data_manager.py | 6 + robot-server/tests/runs/test_run_store.py | 16 +- 9 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index 13676a798eb..a3a4a954961 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -308,9 +308,11 @@ def get_usage_info(self) -> List[ProtocolUsageInfo]: return usage_info - # TODO (spp, 2024-07-22): get files referenced in runs as well async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: - """Get a list of data files referenced in specified protocol's analyses and runs.""" + """Return a list of data files referenced in specified protocol's analyses and runs. + + List returned is in the order in which the data files were uploaded to the server. + """ # Get analyses and runs of protocol_id select_referencing_analysis_ids = sqlalchemy.select(analysis_table.c.id).where( analysis_table.c.protocol_id == protocol_id @@ -318,39 +320,34 @@ async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: select_referencing_run_ids = sqlalchemy.select(run_table.c.id).where( run_table.c.protocol_id == protocol_id ) - # Get all entries in csv table that match the analyses - analysis_csv_file_ids = sqlalchemy.select( + # Get all entries in analysis_csv_table that match the analysis IDs above + select_analysis_csv_file_ids = sqlalchemy.select( analysis_csv_rtp_table.c.file_id ).where( analysis_csv_rtp_table.c.analysis_id.in_(select_referencing_analysis_ids) ) - run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( + # Get all entries in run_csv_table that match the run IDs above + select_run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( run_csv_rtp_table.c.run_id.in_(select_referencing_run_ids) ) - # Get list of data file IDs from the entries - select_analysis_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(analysis_csv_file_ids) - ) - select_run_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(run_csv_file_ids) - ) + with self._sql_engine.begin() as transaction: - analysis_data_files_rows = transaction.execute( - select_analysis_data_file_rows_statement - ).all() - run_data_files_rows = transaction.execute( - select_run_data_file_rows_statement + data_files_rows = transaction.execute( + data_files_table.select() + .where( + data_files_table.c.id.in_(select_analysis_csv_file_ids) + | data_files_table.c.id.in_(select_run_csv_file_ids) + ) + .order_by(sqlite_rowid) ).all() - combine_data_file_rows = set(analysis_data_files_rows + run_data_files_rows) - return [ DataFile( id=sql_row.id, name=sql_row.name, createdAt=sql_row.created_at, ) - for sql_row in combine_data_file_rows + for sql_row in data_files_rows ] def get_referencing_run_ids(self, protocol_id: str) -> List[str]: diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 62b491e6617..7996d5c5237 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -207,6 +207,10 @@ async def create( created_at=created_at, protocol_id=protocol.protocol_id if protocol is not None else None, ) + run_time_parameters = self._run_orchestrator_store.get_run_time_parameters() + self._run_store.insert_csv_rtp( + run_id=run_id, run_time_parameters=run_time_parameters + ) await self._runs_publisher.start_publishing_for_run( get_current_command=self.get_current_command, get_recovery_target_command=self.get_recovery_target_command, @@ -218,7 +222,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, - run_time_parameters=self._run_orchestrator_store.get_run_time_parameters(), + run_time_parameters=run_time_parameters, ) def get(self, run_id: str) -> Union[Run, BadRun]: diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index bbd50b1f713..0de7d08bac6 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -217,7 +217,7 @@ def get_all_csv_rtp(self) -> List[CSVParameterRunResource]: with self._sql_engine.begin() as transaction: csv_rtps = transaction.execute(select_all_csv_rtp).all() - return [_covert_row_to_csv_rtp(row) for row in csv_rtps] + return [_convert_row_to_csv_rtp(row) for row in csv_rtps] def insert_csv_rtp( self, run_id: str, run_time_parameters: List[RunTimeParameter] @@ -543,9 +543,13 @@ def remove(self, run_id: str) -> None: delete_commands = sqlalchemy.delete(run_command_table).where( run_command_table.c.run_id == run_id ) + delete_csv_rtps = sqlalchemy.delete(run_csv_rtp_table).where( + run_csv_rtp_table.c.run_id == run_id + ) with self._sql_engine.begin() as transaction: transaction.execute(delete_actions) transaction.execute(delete_commands) + transaction.execute(delete_csv_rtps) result = transaction.execute(delete_run) if result.rowcount < 1: @@ -574,7 +578,7 @@ def _clear_caches(self) -> None: _run_columns = [run_table.c.id, run_table.c.protocol_id, run_table.c.created_at] -def _covert_row_to_csv_rtp( +def _convert_row_to_csv_rtp( row: sqlalchemy.engine.Row, ) -> CSVParameterRunResource: run_id = row.run_id diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml index 399fc6e445c..9c9980b9608 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml @@ -96,6 +96,7 @@ stages: - displayName: Liquid handling CSV file variableName: liq_handling_csv_file description: A CSV file that contains wells to use for pipetting + type: csv_file file: id: '{csv_file_id}' name: 'sample_record.csv' diff --git a/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml new file mode 100644 index 00000000000..7868ece724f --- /dev/null +++ b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml @@ -0,0 +1,250 @@ +test_name: Test the /protocols/{protocolID}/dataFiles endpoint + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + # The order of these data file uploads is important for this test, + # since the list of data files returned for the specified protocol is in upload order. + # The order in which the files are uploaded in this test is the same as the order in which + # these files are uploaded in the overall integration tests suite. + # Until we add data file cleanup after each test, maintaining this order within the suite + # will be important. + + # sample_record -> test + # sample_plates -> sample_record + # test -> sample_plates + - name: Upload data file 1 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/test.csv' + response: + save: + json: + data_file_1_id: data.id + data_file_1_name: data.name + status_code: + - 201 + - 200 + + - name: Upload data file 2 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_record.csv' + response: + save: + json: + data_file_2_id: data.id + data_file_2_name: data.name + status_code: + - 201 + - 200 + + - name: Upload data file 3 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_plates.csv' + response: + save: + json: + data_file_3_id: data.id + data_file_3_name: data.name + status_code: + - 201 + - 200 + + - name: Upload protocol with CSV file ID + request: + url: '{ot2_server_base_url}/protocols' + method: POST + data: + runTimeParameterFiles: '{{"liq_handling_csv_file": "{data_file_1_id}"}}' + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + run_time_parameters_data1: data.analysisSummaries[0].runTimeParameters + strict: + json:off + status_code: 201 + json: + data: + analysisSummaries: + - id: !anystr + status: pending + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_1_id}' + name: 'test.csv' + + - name: Wait until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + protocolKind: !anything + metadata: !anything + links: !anything + + - name: Start a new analysis with a different CSV file + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + forceReAnalyze: true + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_3_id}' + response: + strict: + - json:off + status_code: 201 + json: + data: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: pending + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_3_id}' + name: 'sample_plates.csv' + + - name: Wait until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + protocolKind: !anything + metadata: !anything + links: !anything + + - name: Create a run from the protocol and a CSV file + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_1_id}' + response: + status_code: 201 + save: + json: + run_id1: data.id + run_time_parameters_data2: data.runTimeParameters + strict: + json:off + json: + data: + id: !anystr + ok: True + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + status: idle + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_1_id}' + name: 'test.csv' + + - name: Create another run from the protocol and a different CSV file + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_2_id}' + response: + status_code: 201 + save: + json: + run_id2: data.id + run_time_parameters_data3: data.runTimeParameters + strict: + json:off + json: + data: + id: !anystr + ok: True + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + status: idle + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_2_id}' + name: 'sample_record.csv' + + - name: Fetch data files used with the protocol so far + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/dataFiles' + response: + status_code: 200 + json: + meta: + cursor: 0 + totalLength: 3 + data: + - id: '{data_file_1_id}' + name: "test.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - id: '{data_file_2_id}' + name: "sample_record.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - id: '{data_file_3_id}' + name: "sample_plates.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index f029e945e20..0f729e62d8d 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -27,7 +27,9 @@ stages: save: json: data_file_id: data.id - status_code: 201 + status_code: + - 201 + - 200 json: data: id: !anystr diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index ff6d4ce7b49..5d413ad7fa3 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -585,7 +585,7 @@ async def test_get_referenced_data_files( subject.insert(protocol_resource_1) await data_files_store.insert( DataFileInfo( - id="data-file-id", + id="data-file-id-1", name="file-name", file_hash="abc123", created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), @@ -632,7 +632,7 @@ async def test_get_referenced_data_files( CSVParameterResource( analysis_id="analysis-id-1", parameter_variable_name="csv-var", - file_id="data-file-id", + file_id="data-file-id-1", ), CSVParameterResource( analysis_id="analysis-id-1", @@ -648,23 +648,20 @@ async def test_get_referenced_data_files( ) result = await subject.get_referenced_data_files("protocol-id") - for data_file in result: - assert data_file in [ - DataFile( - id="data-file-id", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-2", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-3", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - ] - - assert len(result) == 3 + assert result == [ + DataFile( + id="data-file-id-1", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-2", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-3", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + ] diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index dc19c8b4abc..ff1f70da399 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -214,6 +214,7 @@ async def test_create( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, ) + decoy.verify(mock_run_store.insert_csv_rtp(run_id=run_id, run_time_parameters=[])) async def test_create_with_options( @@ -299,6 +300,11 @@ async def test_create_with_options( liquids=engine_state_summary.liquids, runTimeParameters=[bool_parameter, file_parameter], ) + decoy.verify( + mock_run_store.insert_csv_rtp( + run_id=run_id, run_time_parameters=[bool_parameter, file_parameter] + ) + ) async def test_create_engine_error( diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index f4b2b8e154f..74dcffac14f 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -484,7 +484,12 @@ def test_get_all_runs( assert result == expected_result -def test_remove_run(subject: RunStore, mock_runs_publisher: mock.Mock) -> None: +async def test_remove_run( + subject: RunStore, + mock_runs_publisher: mock.Mock, + data_files_store: DataFilesStore, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: """It can remove a previously stored run entry.""" action = RunAction( actionType=RunActionType.PLAY, @@ -498,6 +503,15 @@ def test_remove_run(subject: RunStore, mock_runs_publisher: mock.Mock) -> None: created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) subject.insert_action(run_id="run-id", action=action) + await data_files_store.insert( + DataFileInfo( + id="file-id", + name="my_csv_file.csv", + file_hash="file-hash", + created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), + ) + ) + subject.insert_csv_rtp(run_id="run-id", run_time_parameters=run_time_parameters) subject.remove(run_id="run-id") assert subject.get_all(length=20) == [] From f851fc9a0255ac33ea5038ca57bb574d7b435019 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 7 Aug 2024 15:25:17 -0400 Subject: [PATCH 26/39] refactor(robot-server): RTP handling improvements (#15919) # Overview This PR addresses some refactor suggestions made in previous PRs ## Changelog - check if data file directory exists before deleting it - refactored `FileInUseError`'s message formatting - small code improvement in `completed_analysis_store` ## Risk assessment None. --- .../data_files/data_files_store.py | 23 ++++++------------- .../robot_server/data_files/models.py | 21 ++++++++++++++++- .../protocols/completed_analysis_store.py | 11 +++++---- .../tests/data_files/test_data_files_store.py | 6 +++++ 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/robot-server/robot_server/data_files/data_files_store.py b/robot-server/robot_server/data_files/data_files_store.py index 785046d80a1..a209dfc8e3a 100644 --- a/robot-server/robot_server/data_files/data_files_store.py +++ b/robot-server/robot_server/data_files/data_files_store.py @@ -145,26 +145,17 @@ def remove(self, file_id: str) -> None: transaction.execute(select_ids_used_in_runs).scalars().all() ) if len(files_used_in_analyses) + len(files_used_in_runs) > 0: - analysis_usage_text = ( - f" analyses: {files_used_in_analyses}" - if len(files_used_in_analyses) > 0 - else None - ) - runs_usage_text = ( - f" runs: {files_used_in_runs}" - if len(files_used_in_runs) > 0 - else None - ) - conjunction = " and " if analysis_usage_text and runs_usage_text else "" + raise FileInUseError( data_file_id=file_id, - message=f"Cannot remove file {file_id} as it is being used in" - f" existing{analysis_usage_text or ''}{conjunction}{runs_usage_text or ''}.", + ids_used_in_runs=files_used_in_runs, + ids_used_in_analyses=files_used_in_analyses, ) - transaction.execute(delete_statement) - + result = transaction.execute(delete_statement) + if result.rowcount < 1: + raise FileIdNotFoundError(file_id) file_dir = self._data_files_directory.joinpath(file_id) - if file_dir: + if file_dir.exists(): for file in file_dir.glob("*"): file.unlink() file_dir.rmdir() diff --git a/robot-server/robot_server/data_files/models.py b/robot-server/robot_server/data_files/models.py index f5a9800452b..77a9114203c 100644 --- a/robot-server/robot_server/data_files/models.py +++ b/robot-server/robot_server/data_files/models.py @@ -1,5 +1,6 @@ """Data files models.""" from datetime import datetime +from typing import Set from pydantic import Field @@ -30,7 +31,25 @@ def __init__(self, data_file_id: str) -> None: class FileInUseError(GeneralError): """Error raised when a file being removed is in use.""" - def __init__(self, data_file_id: str, message: str) -> None: + def __init__( + self, + data_file_id: str, + ids_used_in_runs: Set[str], + ids_used_in_analyses: Set[str], + ) -> None: + analysis_usage_text = ( + f" analyses: {ids_used_in_analyses}" + if len(ids_used_in_analyses) > 0 + else None + ) + runs_usage_text = ( + f" runs: {ids_used_in_runs}" if len(ids_used_in_runs) > 0 else None + ) + conjunction = " and " if analysis_usage_text and runs_usage_text else "" + message = ( + f"Cannot remove file {data_file_id} as it is being used in" + f" existing{analysis_usage_text or ''}{conjunction}{runs_usage_text or ''}." + ) super().__init__( message=message, detail={"dataFileId": data_file_id}, diff --git a/robot-server/robot_server/protocols/completed_analysis_store.py b/robot-server/robot_server/protocols/completed_analysis_store.py index bf8cca74871..eb473c7692d 100644 --- a/robot-server/robot_server/protocols/completed_analysis_store.py +++ b/robot-server/robot_server/protocols/completed_analysis_store.py @@ -280,10 +280,13 @@ def get_primitive_rtps_by_analysis_id( with self._sql_engine.begin() as transaction: results = transaction.execute(statement).all() - rtps: Dict[str, PrimitiveAllowedTypes] = {} - for row in results: - param = PrimitiveParameterResource.from_sql_row(row) - rtps.update({param.parameter_variable_name: param.parameter_value}) + param_resources = [ + PrimitiveParameterResource.from_sql_row(row) for row in results + ] + rtps = { + param.parameter_variable_name: param.parameter_value + for param in param_resources + } return rtps def get_csv_rtps_by_analysis_id( diff --git a/robot-server/tests/data_files/test_data_files_store.py b/robot-server/tests/data_files/test_data_files_store.py index 33cb31e2621..caef1599961 100644 --- a/robot-server/tests/data_files/test_data_files_store.py +++ b/robot-server/tests/data_files/test_data_files_store.py @@ -265,3 +265,9 @@ async def test_remove_raises_in_file_in_use( expected_error_message = "Cannot remove file file-id as it is being used in existing analyses: {'analysis-id'}." with pytest.raises(FileInUseError, match=expected_error_message): subject.remove(file_id="file-id") + + +def test_remove_raise_for_nonexistent_id(subject: DataFilesStore) -> None: + """It should raise FileIdNotFound error.""" + with pytest.raises(FileIdNotFoundError, match="Data file file-id was not found."): + subject.remove(file_id="file-id") From 63fd8fcaeb41e3d8a0f90a3913a1bc8998d025cf Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:31:43 -0400 Subject: [PATCH 27/39] refactor(app): fix title element props in LabwareStackModal (#15922) Pass `LegacyModal` title text and icon elements as separate props in `LabwareStackModal` --- .../ProtocolRun/SetupLabware/LabwareStackModal.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx index b65a8b38eb4..83fdf1d9260 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -155,13 +155,9 @@ export const LabwareStackModal = ( - - - {t('stacked_slot')} - - } + title={t('stacked_slot')} + titleElement1={} + titleElement2={} childrenPadding={0} marginLeft="0" > From a6c0ba74b2f528eefb1cc08b04aabc8f5abac2a5 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Wed, 7 Aug 2024 15:32:06 -0400 Subject: [PATCH 28/39] feat(app,components): add stacked badge to protocol setup labware maps (#15925) adds a stacked badge icon to BaseDeck and uses it in desktop/ODD protocol setup labware maps. adds a highlight on hover for desktop stacked labware on modules. closes PLAT-372 --- .../SetupLabware/SetupLabwareMap.tsx | 18 +++++++++++++++- .../ProtocolSetupLabware/LabwareMapView.tsx | 1 + .../src/hardware-sim/BaseDeck/BaseDeck.tsx | 21 +++++++++++++++++++ .../src/molecules/DeckInfoLabel/index.tsx | 9 ++++---- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index ae8f3bbea02..9a3c04fdc5f 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -43,6 +43,9 @@ export function SetupLabwareMap({ labwareStackDetailsLabwareId, setLabwareStackDetailsLabwareId, ] = React.useState(null) + const [hoverLabwareId, setHoverLabwareId] = React.useState( + null + ) if (protocolAnalysis == null) return null @@ -81,14 +84,27 @@ export function SetupLabwareMap({ : {}, nestedLabwareDef: topLabwareDefinition, + highlightLabware: + topLabwareDefinition != null && + topLabwareId != null && + hoverLabwareId === topLabwareId, + stacked: topLabwareDefinition != null && topLabwareId != null, moduleChildren: ( // open modal { - if (topLabwareDefinition != null) { + if (topLabwareDefinition != null && topLabwareId != null) { setLabwareStackDetailsLabwareId(topLabwareId) } }} + onMouseEnter={() => { + if (topLabwareDefinition != null && topLabwareId != null) { + setHoverLabwareId(topLabwareId) + } + }} + onMouseLeave={() => { + setHoverLabwareId(null) + }} cursor="pointer" > {topLabwareDefinition != null && topLabwareId != null ? ( diff --git a/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx index e74d478ce70..1e00d266008 100644 --- a/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx @@ -72,6 +72,7 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { : undefined, highlightLabware: true, moduleChildren: null, + stacked: topLabwareDefinition != null && topLabwareId != null, } }) diff --git a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx index 50791c42b3c..5dc076b1781 100644 --- a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx +++ b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx @@ -19,11 +19,13 @@ import { STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, } from '@opentrons/shared-data' +import { DeckInfoLabel } from '../../molecules/DeckInfoLabel' import { RobotCoordinateSpace } from '../RobotCoordinateSpace' import { Module } from '../Module' import { LabwareRender } from '../Labware' import { FlexTrash } from '../Deck/FlexTrash' import { DeckFromLayers } from '../Deck/DeckFromLayers' +import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' import { SlotLabels } from '../Deck' import { COLORS } from '../../helix-design-system' @@ -55,6 +57,7 @@ export interface LabwareOnDeck { labwareChildren?: React.ReactNode onLabwareClick?: () => void highlight?: boolean + stacked?: boolean } export interface ModuleOnDeck { @@ -67,6 +70,7 @@ export interface ModuleOnDeck { moduleChildren?: React.ReactNode onLabwareClick?: () => void highlightLabware?: boolean + stacked?: boolean } interface BaseDeckProps { deckConfig: DeckConfiguration @@ -243,6 +247,7 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { moduleChildren, onLabwareClick, highlightLabware, + stacked = false, }) => { const slotPosition = getPositionFromSlotId( moduleLocation.slotName, @@ -273,6 +278,7 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { /> ) : null} {moduleChildren} + {stacked ? : null} ) : null } @@ -286,6 +292,7 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { missingTips, onLabwareClick, highlight, + stacked = false, }) => { if ( labwareLocation === 'offDeck' || @@ -316,6 +323,7 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { highlight={highlight} /> {labwareChildren} + {stacked ? : null} ) : null } @@ -325,3 +333,16 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { ) } + +function StackedBadge(): JSX.Element { + return ( + + + + ) +} diff --git a/components/src/molecules/DeckInfoLabel/index.tsx b/components/src/molecules/DeckInfoLabel/index.tsx index 86666c3263f..3888029a55b 100644 --- a/components/src/molecules/DeckInfoLabel/index.tsx +++ b/components/src/molecules/DeckInfoLabel/index.tsx @@ -26,6 +26,7 @@ interface HardwareIconProps extends StyleProps { // type union requires one of deckLabel or iconName, but not both export type DeckInfoLabelProps = (DeckLabelProps | HardwareIconProps) & { highlight?: boolean + svgSize?: string | number } export const DeckInfoLabel = styled(DeckInfoLabelComponent)` @@ -42,8 +43,8 @@ export const DeckInfoLabel = styled(DeckInfoLabelComponent)` props.height ?? SPACING.spacing20}; // prevents the icon from being squished > svg { - height: 0.875rem; - width: 0.875rem; + height: ${props => props.svgSize ?? '0.875rem'}; + width: ${props => props.svgSize ?? '0.875rem'}; } @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -53,8 +54,8 @@ export const DeckInfoLabel = styled(DeckInfoLabelComponent)` ${props => props.deckLabel != null ? SPACING.spacing8 : SPACING.spacing6}; > svg { - height: 1.25rem; - width: 1.25rem; + height: ${props => props.svgSize ?? '1.25rem'}; + width: ${props => props.svgSize ?? '1.25rem'}; } } ` From b7a008b4f97440d2663776808bad582d2145421e Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Wed, 7 Aug 2024 15:44:53 -0400 Subject: [PATCH 29/39] refactor(api, robot-server): CSV parameter improvements (#15907) # Overview This PR follows up on #15855 with various small refactors/improvements. The biggest change in this PR is refactoring how we open the CSV parameter files. Previously we were opening them when we first initialized the parameter. This proved to be a potential issue because we were only closing the files when we reached the execute step of running the protocol (i.e. in a `finally` clause within `run_protocol`), yet there was the small yet possible chance of not reaching that step (for instance if the initial home command failed unexpectedly). To solve this, we are now passing the `Path` object to the `CSVParameter` interface and only opening the file when it is first accessed in the protocol, meaning it will _always_ occur within that try/except block. Another change introduced is properly raising a 400 error for `POST`s to the `/runs`, `/protocols` and `/protocols/{protocolId}/analyses` endpoints when referencing data files (for runtime parameter files) that don't exist. API Version is now also enforced for using `add_csv_file` which now requires your protocol to be at 2.20. The api version is also now passed down to the `CSVParameter` interface, though it is not currently used to gate anything yet. ## Test Plan and Hands on Testing Tested end to end on hardware with the same protocol/csv files as #15855. ## Changelog - Pass `Path` to `CSVParameter` interface and open files within that. - Raise a 400 error when referencing non existent data file IDs for `/runs`, `/protocols` and `/protocols/{protocolId}/analyses` - Require API version 2.20 for `add_csv_file` - Fixes/improvements for testing CSV files/file opening in protocol api ## Review requests ## Risk assessment Low. --- api/src/opentrons/protocol_api/__init__.py | 2 +- .../protocol_api/_parameter_context.py | 37 +---- .../protocol_runner/protocol_runner.py | 1 - .../python_protocol_wrappers.py | 2 - .../opentrons/protocols/execution/execute.py | 19 ++- .../parameters/csv_parameter_definition.py | 20 +-- .../parameters/csv_parameter_interface.py | 64 +++++++++ .../parameters/parameter_file_reader.py | 26 ++++ .../opentrons/protocols/parameters/types.py | 64 +-------- api/tests/opentrons/cli/test_cli.py | 4 +- .../protocol_runner/test_protocol_runner.py | 2 - .../test_csv_parameter_definition.py | 30 ++-- .../test_csv_parameter_interface.py | 133 ++++++++++++------ .../parameters/test_parameter_file_reader.py | 34 +++++ .../robot_server/data_files/models.py | 10 +- .../robot_server/data_files/router.py | 9 +- robot-server/robot_server/protocols/router.py | 26 ++-- .../robot_server/runs/router/base_router.py | 21 +-- ...basic_transfer_with_run_time_parameters.py | 2 +- 19 files changed, 309 insertions(+), 197 deletions(-) create mode 100644 api/src/opentrons/protocols/parameters/csv_parameter_interface.py create mode 100644 api/src/opentrons/protocols/parameters/parameter_file_reader.py create mode 100644 api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index ed95efca22d..8cc4bd1154e 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -12,7 +12,7 @@ from opentrons.protocols.parameters.exceptions import ( RuntimeParameterRequired as RuntimeParameterRequiredError, ) -from opentrons.protocols.parameters.types import CSVParameter +from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter from .protocol_context import ProtocolContext from .deck import Deck diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index f334c2ef1d2..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.""" -import tempfile 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, @@ -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, @@ -240,41 +241,13 @@ def initialize_csv_files( f"File Id was provided for the parameter '{variable_name}'," f" but '{variable_name}' is not a CSV parameter." ) - # TODO(jbl 2024-08-02) This file opening should be moved elsewhere to provide more flexibility with files - # that may be opened as non-text or non-UTF-8 + # The parent folder in the path will be the file ID, so we can use that to resolve that here file_id = file_path.parent.name file_name = file_path.name - # Read the contents of the actual file - with file_path.open() as csv_file: - contents = csv_file.read() - - # Open a temporary file with write permissions and write contents to that - temporary_file = tempfile.NamedTemporaryFile("r+") - temporary_file.write(contents) - temporary_file.flush() - - # Open a new file handler for the temporary file with read-only permissions and close the other - parameter_file = open(temporary_file.name, "r") - temporary_file.close() - parameter.file_info = FileInfo(id=file_id, name=file_name) - parameter.value = parameter_file - - def close_csv_files(self) -> None: - """Close all file handlers for CSV parameters. - - :meta private: - - This is intended for Opentrons internal use only and is not a guaranteed API. - """ - for parameter in self._parameters.values(): - if ( - isinstance(parameter, csv_parameter_definition.CSVParameterDefinition) - and parameter.value is not None - ): - parameter.value.close() + parameter.value = file_path def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. @@ -299,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_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index b0aabad34a4..22c809bcde5 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -244,7 +244,6 @@ async def run_func() -> None: await self._protocol_executor.execute( protocol=protocol, context=context, - parameter_context=self._parameter_context, run_time_parameters_with_overrides=run_time_parameters_with_overrides, ) diff --git a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py index 17f82b88846..f20012f1dfe 100644 --- a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py +++ b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py @@ -153,7 +153,6 @@ class PythonProtocolExecutor: async def execute( protocol: Protocol, context: ProtocolContext, - parameter_context: Optional[ParameterContext], run_time_parameters_with_overrides: Optional[Parameters], ) -> None: """Execute a PAPIv2 protocol with a given ProtocolContext in a child thread.""" @@ -161,7 +160,6 @@ async def execute( run_protocol, protocol, context, - parameter_context, run_time_parameters_with_overrides, ) diff --git a/api/src/opentrons/protocols/execution/execute.py b/api/src/opentrons/protocols/execution/execute.py index 46f429839f5..ff52b3b1dc5 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -1,7 +1,7 @@ import logging from typing import Optional -from opentrons.protocol_api import ProtocolContext, ParameterContext +from opentrons.protocol_api import ProtocolContext from opentrons.protocol_api._parameters import Parameters from opentrons.protocols.execution.execute_python import exec_run from opentrons.protocols.execution.json_dispatchers import ( @@ -15,20 +15,21 @@ 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__) def run_protocol( protocol: Protocol, context: ProtocolContext, - parameter_context: Optional[ParameterContext] = None, run_time_parameters_with_overrides: Optional[Parameters] = None, ) -> None: """Run a protocol. :param protocol: The :py:class:`.protocols.types.Protocol` to execute :param context: The protocol context to use. - :param parameter_context: The parameter context to use if running with runtime parameters. :param run_time_parameters_with_overrides: Run time parameters defined in the protocol, updated with the run's RTP override values. When we are running either simulate or execute, this will be None (until RTP is supported in cli commands) @@ -48,9 +49,15 @@ def run_protocol( except Exception: raise finally: - # TODO(jbl 2024-08-02) this should be more tightly bound to the opening of the csv files - if parameter_context is not None: - parameter_context.close_csv_files() + 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/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 a4b4e30e5c6..631d686b7e7 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -1,67 +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 - - -# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder -class CSVParameter: - def __init__(self, csv_file: Optional[TextIO]) -> None: - self._file = csv_file - 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/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/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 14307411d53..cd945c33e64 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -668,7 +668,6 @@ async def test_load_legacy_python( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, - parameter_context=python_runner_subject._parameter_context, run_time_parameters_with_overrides=None, ), ) @@ -812,7 +811,6 @@ async def test_load_legacy_json( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, - parameter_context=None, run_time_parameters_with_overrides=None, ), ) diff --git a/api/tests/opentrons/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/robot-server/robot_server/data_files/models.py b/robot-server/robot_server/data_files/models.py index 77a9114203c..f2da43bb0f6 100644 --- a/robot-server/robot_server/data_files/models.py +++ b/robot-server/robot_server/data_files/models.py @@ -1,11 +1,12 @@ """Data files models.""" from datetime import datetime -from typing import Set +from typing import Literal, Set from pydantic import Field from opentrons_shared_data.errors import GeneralError +from robot_server.errors.error_responses import ErrorDetails from robot_server.service.json_api import ResourceModel @@ -54,3 +55,10 @@ def __init__( message=message, detail={"dataFileId": data_file_id}, ) + + +class FileIdNotFound(ErrorDetails): + """An error returned when specified file id was not found on the robot.""" + + id: Literal["FileIdNotFound"] = "FileIdNotFound" + title: str = "Specified file id not found on the robot" diff --git a/robot-server/robot_server/data_files/router.py b/robot-server/robot_server/data_files/router.py index 35d23fb5d51..6d0d153ae68 100644 --- a/robot-server/robot_server/data_files/router.py +++ b/robot-server/robot_server/data_files/router.py @@ -21,7 +21,7 @@ ) from .data_files_store import DataFilesStore, DataFileInfo from .file_auto_deleter import DataFileAutoDeleter -from .models import DataFile, FileIdNotFoundError +from .models import DataFile, FileIdNotFoundError, FileIdNotFound from ..protocols.dependencies import get_file_hasher, get_file_reader_writer from ..service.dependencies import get_current_time, get_unique_id @@ -49,13 +49,6 @@ class FileNotFound(ErrorDetails): title: str = "Specified file path not found on the robot" -class FileIdNotFound(ErrorDetails): - """An error returned when specified file id was not found on the robot.""" - - id: Literal["FileIdNotFound"] = "FileIdNotFound" - title: str = "Specified file id not found on the robot" - - class UnexpectedFileFormat(ErrorDetails): """An error returned when specified file is not in expected format.""" diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 5380464bae4..2ea216f9f29 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -53,7 +53,7 @@ get_data_files_store, ) from robot_server.data_files.data_files_store import DataFilesStore -from robot_server.data_files.models import DataFile +from robot_server.data_files.models import DataFile, FileIdNotFound, FileIdNotFoundError from .analyses_manager import AnalysesManager, FailedToInitializeAnalyzer @@ -196,6 +196,7 @@ class ProtocolLinks(BaseModel): responses={ status.HTTP_200_OK: {"model": SimpleBody[Protocol]}, status.HTTP_201_CREATED: {"model": SimpleBody[Protocol]}, + status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_422_UNPROCESSABLE_ENTITY: { "model": ErrorBody[Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch]] }, @@ -323,10 +324,13 @@ async def create_protocol( # noqa: C901 assert file.filename is not None buffered_files = await file_reader_writer.read(files=files) # type: ignore[arg-type] - rtp_paths = { - name: data_files_directory / file_id / data_files_store.get(file_id).name - for name, file_id in parsed_rtp_files.items() - } + try: + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in parsed_rtp_files.items() + } + except FileIdNotFoundError as e: + raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) content_hash = await file_hasher.hash(buffered_files) cached_protocol_id = protocol_store.get_id_by_hash(content_hash) @@ -698,6 +702,7 @@ async def delete_protocol_by_id( responses={ status.HTTP_200_OK: {"model": SimpleMultiBody[AnalysisSummary]}, status.HTTP_201_CREATED: {"model": SimpleMultiBody[AnalysisSummary]}, + status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, @@ -732,10 +737,13 @@ async def create_protocol_analysis( rtp_files = request_body.data.runTimeParameterFiles if request_body else {} - rtp_paths = { - name: data_files_directory / file_id / data_files_store.get(file_id).name - for name, file_id in rtp_files.items() - } + try: + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + except FileIdNotFoundError as e: + raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) try: ( diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index d18be815713..7000882b965 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -14,11 +14,11 @@ from opentrons_shared_data.errors import ErrorCodes from opentrons.protocol_engine.types import CSVRuntimeParamPaths - from opentrons.protocol_engine import ( errors as pe_errors, ) +from robot_server.data_files.models import FileIdNotFound, FileIdNotFoundError from robot_server.data_files.dependencies import ( get_data_files_directory, get_data_files_store, @@ -149,11 +149,12 @@ async def get_run_data_from_url( status_code=status.HTTP_201_CREATED, responses={ status.HTTP_201_CREATED: {"model": SimpleBody[Run]}, + status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunAlreadyActive]}, }, ) -async def create_run( +async def create_run( # noqa: C901 request_body: Optional[RequestModel[RunCreate]] = None, run_data_manager: RunDataManager = Depends(get_run_data_manager), protocol_store: ProtocolStore = Depends(get_protocol_store), @@ -199,12 +200,16 @@ async def create_run( ) rtp_paths: Optional[CSVRuntimeParamPaths] = None - # TODO(jbl 2024-08-02) raise the proper error if file ids don't exist - if rtp_files: - rtp_paths = { - name: data_files_directory / file_id / data_files_store.get(file_id).name - for name, file_id in rtp_files.items() - } + try: + if rtp_files: + rtp_paths = { + name: data_files_directory + / file_id + / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + except FileIdNotFoundError as e: + raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) protocol_resource = None diff --git a/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py b/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py index 0e038331101..59e2e9b5f9b 100644 --- a/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py +++ b/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py @@ -1,7 +1,7 @@ from opentrons.protocol_api import ProtocolContext, ParameterContext metadata = { - "apiLevel": "2.18", + "apiLevel": "2.20", "author": "engineer@opentrons.com", "protocolName": "basic_transfer_standalone", } From 604993f9efcf4c570ff7742da9109323facd89e2 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Wed, 7 Aug 2024 15:54:02 -0400 Subject: [PATCH 30/39] fix(app): Quick transfer copy and logic fixes (#15923) Fix RQA-2918, RQA-2917, RQA-2907 --- .../QuickTransferAdvancedSettings/BlowOut.tsx | 4 ++-- .../QuickTransferAdvancedSettings/Delay.tsx | 13 ++++++++++++- .../QuickTransferAdvancedSettings/Mix.tsx | 13 ++++++++++++- .../QuickTransferAdvancedSettings/TouchTip.tsx | 2 +- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx index a2d536459c0..abab3a9ae34 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx @@ -61,13 +61,13 @@ export const useBlowOutLocationOptions = ( }> = [] if (transferType !== 'distribute') { blowOutLocationItems.push({ - location: 'source_well', + location: 'dest_well', description: t('blow_out_source_well'), }) } if (transferType !== 'consolidate') { blowOutLocationItems.push({ - location: 'dest_well', + location: 'source_well', description: t('blow_out_destination_well'), }) } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx index 4b8414addbb..e6e8a5c707b 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx @@ -138,9 +138,19 @@ export function Delay(props: DelayProps): JSX.Element { }) : null + // allow a maximum of 10 digits for delay duration + const durationRange = { min: 1, max: 9999999999 } + const durationError = + delayDuration != null && + (delayDuration < durationRange.min || delayDuration > durationRange.max) + ? t(`value_out_of_range`, { + min: durationRange.min, + max: durationRange.max, + }) + : null let buttonIsDisabled = false if (currentStep === 2) { - buttonIsDisabled = delayDuration == null + buttonIsDisabled = delayDuration == null || durationError != null } else if (currentStep === 3) { buttonIsDisabled = positionError != null || position == null } @@ -199,6 +209,7 @@ export function Delay(props: DelayProps): JSX.Element { diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx index 46727cb1228..91c75103d6e 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx @@ -118,11 +118,21 @@ export function Mix(props: MixProps): JSX.Element { }) : null + const repititionRange = { min: 1, max: 999 } + const repititionError = + mixReps != null && + (mixReps < repititionRange.min || mixReps > repititionRange.max) + ? t(`value_out_of_range`, { + min: repititionRange.min, + max: repititionRange.max, + }) + : null + let buttonIsDisabled = false if (currentStep === 2) { buttonIsDisabled = mixVolume == null || volumeError != null } else if (currentStep === 3) { - buttonIsDisabled = mixReps == null + buttonIsDisabled = mixReps == null || repititionError != null } return createPortal( @@ -219,6 +229,7 @@ export function Mix(props: MixProps): JSX.Element { diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx index 5791ac2813c..180f88311af 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx @@ -183,7 +183,7 @@ export function TouchTip(props: TouchTipProps): JSX.Element { From 6d9db72fbae36769550257872616f1486cebd21a Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Wed, 7 Aug 2024 16:12:44 -0400 Subject: [PATCH 31/39] fix(api): Lld math mistake round 2 (#15924) # Overview ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --------- Co-authored-by: caila-marashaj --- api/src/opentrons/hardware_control/ot3api.py | 2 +- api/tests/opentrons/hardware_control/test_ot3_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 5f9c9840834..08dcb488aab 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2717,7 +2717,7 @@ async def liquid_probe( # probe_start_pos.z + z_distance of pass - pos.z should be < max_z_dist # due to rounding errors this can get caught in an infinite loop when the distance is almost equal # so we check to see if they're within 0.01 which is 1/5th the minimum movement distance from move_utils.py - while (probe_start_pos.z - pos.z) < (max_z_dist + 0.01): + while (probe_start_pos.z - pos.z) < (max_z_dist - 0.01): # safe distance so we don't accidentally aspirate liquid if we're already close to liquid safe_plunger_pos = top_types.Point( pos.x, pos.y, pos.z + probe_safe_reset_mm diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index a6ae8e870d1..20a8f090374 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -958,7 +958,7 @@ async def test_liquid_probe_plunger_moves( mount_travel_distance = mount_speed * mount_travel_time max_z_distance -= mount_travel_distance - move_mount_z_time = (max_z_distance + probe_safe_reset_mm) / mount_speed + 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 @@ -1167,7 +1167,7 @@ async def _fake_pos_update_and_raise( OT3Mount.LEFT, fake_max_z_dist, fake_settings_aspirate ) # assert that it went through 4 passes and then prepared to aspirate - assert mock_move_to_plunger_bottom.call_count == 4 + assert mock_move_to_plunger_bottom.call_count == 5 @pytest.mark.parametrize( From cffa307b896e4c7f6567f27410c11a473a2fc566 Mon Sep 17 00:00:00 2001 From: TamarZanzouri Date: Wed, 7 Aug 2024 16:16:32 -0400 Subject: [PATCH 32/39] feature(app, odd): show full error list in the run summery (#15909) # Overview closes https://opentrons.atlassian.net/browse/EXEC-635 (minos a few bug fixes ;-) ) show full error list in the run summery modal. ## Test Plan and Hands on Testing - run the desktop app/odd. - enter ER mode and make sure you encountered command errors. - finish the run and click the view error details -> make sure you can see the error encountered. ## Changelog - added get calls for the run command error list - updated FailedRunModal to show the full error list if we got it. ## Risk assessment low. should not affect existing code. --- .../src/runs/commands/getRunCommandErrors.ts | 19 +++ api-client/src/runs/commands/types.ts | 7 +- api-client/src/runs/index.ts | 1 + .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 40 +++++-- .../Devices/ProtocolRun/RunFailedModal.tsx | 63 ++++++++-- .../__tests__/ProtocolRunHeader.test.tsx | 23 +++- .../RunningProtocol/RunFailedModal.tsx | 111 ++++++++++++------ app/src/pages/RunSummary/index.tsx | 11 ++ react-api-client/src/runs/index.ts | 1 + .../src/runs/useRunCommandErrors.ts | 43 +++++++ 10 files changed, 262 insertions(+), 57 deletions(-) create mode 100644 api-client/src/runs/commands/getRunCommandErrors.ts create mode 100644 react-api-client/src/runs/useRunCommandErrors.ts 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/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 5d9821cc5a6..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 { @@ -112,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' @@ -166,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 }) @@ -258,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, @@ -266,9 +278,8 @@ export function ProtocolRunHeader({ ...robotAnalyticsData, }, }) - closeCurrentRun() } - }, [runStatus, isRunCurrent, runId, closeCurrentRun]) + }, [runStatus, isRunCurrent, runId]) const startedAtTimestamp = startedAt != null ? formatTimestamp(startedAt) : EMPTY_TIMESTAMP @@ -333,6 +344,7 @@ export function ProtocolRunHeader({ runId={runId} setShowRunFailedModal={setShowRunFailedModal} highestPriorityError={highestPriorityError} + commandErrorList={commandErrorList} /> ) : null} void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void + commandErrorList?: RunCommandErrors isResetRunLoading: boolean isRunCurrent: boolean highestPriorityError?: RunError | null @@ -879,6 +893,7 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { handleClearClick, isClosingCurrentRun, setShowRunFailedModal, + commandErrorList, highestPriorityError, isResetRunLoading, isRunCurrent, @@ -914,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/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index cabf46391be..d193623aaa8 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -23,8 +23,9 @@ import { import { LegacyModal } from '../../../molecules/LegacyModal' import { useDownloadRunLog } from '../hooks' -import type { RunError } from '@opentrons/api-client' +import type { RunError, RunCommandErrors } from '@opentrons/api-client' import type { LegacyModalProps } from '../../../molecules/LegacyModal' +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: LegacyModalProps = { @@ -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/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 872dff5771f..45f84024b49 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -23,6 +23,7 @@ import { useEstopQuery, useDoorQuery, useInstrumentsQuery, + useRunCommandErrors, } from '@opentrons/react-api-client' import { getPipetteModelSpecs, @@ -183,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', @@ -364,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) @@ -480,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, @@ -852,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 () => { diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx index c4db8a35360..636cf850ffd 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx @@ -20,30 +20,38 @@ import { SmallButton } from '../../../atoms/buttons' import { Modal } from '../../../molecules/Modal' import type { ModalHeaderBaseProps } from '../../../molecules/Modal/types' -import type { RunError } from '@opentrons/api-client' +import type { RunCommandErrors, RunError } from '@opentrons/api-client' + +import type { RunCommandError } from '@opentrons/shared-data' interface RunFailedModalProps { runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void errors?: RunError[] + commandErrorList?: RunCommandErrors } export function RunFailedModal({ runId, setShowRunFailedModal, errors, + commandErrorList, }: RunFailedModalProps): JSX.Element | null { const { t, i18n } = useTranslation(['run_details', 'shared', 'branded']) const navigate = useNavigate() const { stopRun } = useStopRunMutation() const [isCanceling, setIsCanceling] = React.useState(false) - if (errors == null || errors.length === 0) return null + if ( + (errors == null || errors.length === 0) && + (commandErrorList == null || commandErrorList.data.length === 0) + ) + return null const modalHeader: ModalHeaderBaseProps = { title: t('run_failed_modal_title'), } - const highestPriorityError = getHighestPriorityError(errors) + const highestPriorityError = getHighestPriorityError(errors ?? []) const handleClose = (): void => { setIsCanceling(true) @@ -60,6 +68,54 @@ export function RunFailedModal({ }, }) } + + interface ErrorContentProps { + errors: RunCommandError[] + isSingleError: boolean + } + const ErrorContent = ({ + errors, + isSingleError, + }: ErrorContentProps): JSX.Element => { + return ( + <> + + {isSingleError + ? t('error_info', { + errorType: errors[0].errorType, + errorCode: errors[0].errorCode, + }) + : `${errors.length} errors`} + + + + {' '} + {errors.map((error, index) => ( + + {' '} + {isSingleError + ? error.detail + : `${error.errorCode}: ${error.detail}`} + + ))} + + + + ) + } + return ( - - {t('error_info', { - errorType: highestPriorityError.errorType, - errorCode: highestPriorityError.errorCode, - })} - - - - - {highestPriorityError.detail} - - - - - {t('branded:contact_information')} - + 0 + ? commandErrorList?.data + : [] + } + isSingleError={!!highestPriorityError} + /> + + {t('branded:contact_information')} + ) : null} ( + runId: string | null, + params?: GetCommandsParams | null, + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const nullCheckedParams = params ?? DEFAULT_PARAMS + + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && runId != null && options.enabled !== false, + } + const { cursor, pageLength } = nullCheckedParams + const query = useQuery( + [host, 'runs', runId, 'commandErrors', cursor, pageLength], + () => { + return getRunCommandErrors( + host as HostConfig, + runId as string, + nullCheckedParams + ).then(response => response.data) + }, + allOptions + ) + + return query +} From 157701f1279c18cf9dcdfb9724a59d820a5293c7 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Wed, 7 Aug 2024 16:23:53 -0400 Subject: [PATCH 33/39] chore(app): add helix caption bold to storybook (#15927) adds helix caption bold to storybook re PLAT-408 --- app/src/DesignTokens/Typography/Typography.stories.tsx | 1 + 1 file changed, 1 insertion(+) 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'], From 503f33d46dc66c9ebce89e346b687a41fd969b6e Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Wed, 7 Aug 2024 16:59:14 -0400 Subject: [PATCH 34/39] chore: 8.0.0 alpha release notes (#15891) # Overview Release notes for 8.0.0. ## Test Plan and Hands on Testing Check display when alpha0 is cut. ## Changelog The usual two files. ## Review requests Any features, bugs, or known issues that need to be added? ## Risk assessment nil --- api/release-notes.md | 20 ++++++++++++++++++++ app-shell/build/release-notes.md | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) 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/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 From 57eed21702561ff83d948ef752e968b319b7a746 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 17:01:50 -0400 Subject: [PATCH 35/39] feat(app, api-client, react-api-client): add api-client function for `getCsvFile` (#15926) Add new api-client function for `getCsvFile`, which takes a fileId and returns `CsvFileData`: ``` { id: string createdAt: string name: string } ``` Wrap in react query hook and implement in `HistoricalProtocolRunDrawer`. --- api-client/src/dataFiles/getCsvFile.ts | 12 +++ api-client/src/dataFiles/index.ts | 1 + api-client/src/dataFiles/types.ts | 4 +- .../Devices/HistoricalProtocolRun.tsx | 9 +- .../Devices/HistoricalProtocolRunDrawer.tsx | 98 +++++++++++-------- .../__tests__/HistoricalProtocolRun.test.tsx | 3 + .../__tests__/useCsvFileQuery.test.tsx | 78 +++++++++++++++ react-api-client/src/dataFiles/index.ts | 1 + .../src/dataFiles/useCsvFileQuery.ts | 25 +++++ .../src/dataFiles/useCsvFileRawQuery.ts | 2 +- 10 files changed, 185 insertions(+), 48 deletions(-) create mode 100644 api-client/src/dataFiles/getCsvFile.ts create mode 100644 react-api-client/src/dataFiles/__tests__/useCsvFileQuery.test.tsx create mode 100644 react-api-client/src/dataFiles/useCsvFileQuery.ts 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/app/src/organisms/Devices/HistoricalProtocolRun.tsx b/app/src/organisms/Devices/HistoricalProtocolRun.tsx index d9fe2e823a4..0b8102f20a7 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRun.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRun.tsx @@ -13,7 +13,6 @@ import { SPACING, LegacyStyledText, } from '@opentrons/components' -import { useAllCsvFilesQuery } from '@opentrons/react-api-client' import { formatInterval } from '../RunTimeControl/utils' import { formatTimestamp } from './utils' import { EMPTY_TIMESTAMP } from './constants' @@ -41,9 +40,9 @@ export function HistoricalProtocolRun( const { t } = useTranslation('run_details') const { run, protocolName, robotIsBusy, robotName, protocolKey } = props const [drawerOpen, setDrawerOpen] = React.useState(false) - const { data: protocolFileData } = useAllCsvFilesQuery(run.protocolId ?? '') - const allProtocolDataFiles = - protocolFileData != null ? protocolFileData.data : [] + const countRunDataFiles = run.runTimeParameters.filter( + parameter => 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/__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/react-api-client/src/dataFiles/__tests__/useCsvFileQuery.test.tsx b/react-api-client/src/dataFiles/__tests__/useCsvFileQuery.test.tsx new file mode 100644 index 00000000000..83e7e0be504 --- /dev/null +++ b/react-api-client/src/dataFiles/__tests__/useCsvFileQuery.test.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { getCsvFile } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useCsvFileQuery } from '..' + +import type { + CsvFileData, + CsvFileDataResponse, + HostConfig, + Response, +} from '@opentrons/api-client' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const FILE_ID = 'file123' +const FILE_NAME = 'my_file.csv' +const FILE_CONTENT_RESPONSE = { + data: { + name: FILE_NAME, + id: FILE_ID, + createdAt: '2024-06-07T19:19:56.268029+00:00', + } as CsvFileData, +} as CsvFileDataResponse + +describe('useCsvFileQuery hook', () => { + let wrapper: React.FunctionComponent<{ children: React.ReactNode }> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{ + children: React.ReactNode + }> = ({ children }) => ( + {children} + ) + + wrapper = clientProvider + }) + + it('should return no data if no host', () => { + vi.mocked(useHost).mockReturnValue(null) + + const { result } = renderHook(() => useCsvFileQuery(FILE_ID), { + wrapper, + }) + + expect(result.current.data).toBeUndefined() + }) + + it('should return no data if the get file request fails', () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getCsvFile).mockRejectedValue('oh no') + + const { result } = renderHook(() => useCsvFileQuery(FILE_ID), { + wrapper, + }) + expect(result.current.data).toBeUndefined() + }) + + it('should return file data if successful request', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getCsvFile).mockResolvedValue({ + data: FILE_CONTENT_RESPONSE, + } as Response) + + const { result } = renderHook(() => useCsvFileQuery(FILE_ID), { + wrapper, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(FILE_CONTENT_RESPONSE) + }) + }) +}) diff --git a/react-api-client/src/dataFiles/index.ts b/react-api-client/src/dataFiles/index.ts index cd6fe47daf0..267fe7fe8d9 100644 --- a/react-api-client/src/dataFiles/index.ts +++ b/react-api-client/src/dataFiles/index.ts @@ -1,2 +1,3 @@ +export { useCsvFileQuery } from './useCsvFileQuery' export { useCsvFileRawQuery } from './useCsvFileRawQuery' export { useUploadCsvFileMutation } from './useUploadCsvFileMutation' diff --git a/react-api-client/src/dataFiles/useCsvFileQuery.ts b/react-api-client/src/dataFiles/useCsvFileQuery.ts new file mode 100644 index 00000000000..808551cecbf --- /dev/null +++ b/react-api-client/src/dataFiles/useCsvFileQuery.ts @@ -0,0 +1,25 @@ +import { useQuery } from 'react-query' +import { getCsvFile } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { UseQueryOptions, UseQueryResult } from 'react-query' +import type { HostConfig, CsvFileDataResponse } from '@opentrons/api-client' + +export function useCsvFileQuery( + fileId: string, + options?: UseQueryOptions +): UseQueryResult { + const host = useHost() + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && fileId !== null, + } + + const query = useQuery( + [host, 'dataFiles', fileId], + () => + getCsvFile(host as HostConfig, fileId).then(response => response.data), + allOptions + ) + return query +} diff --git a/react-api-client/src/dataFiles/useCsvFileRawQuery.ts b/react-api-client/src/dataFiles/useCsvFileRawQuery.ts index 22cae3ad920..3114dc9d5fc 100644 --- a/react-api-client/src/dataFiles/useCsvFileRawQuery.ts +++ b/react-api-client/src/dataFiles/useCsvFileRawQuery.ts @@ -19,7 +19,7 @@ export function useCsvFileRawQuery( } const query = useQuery( - [host, `/dataFiles/${fileId}/download`], + [host, 'dataFiles', fileId, 'download'], () => getCsvFileRaw(host as HostConfig, fileId).then(response => response.data), allOptions From 8430cd1e25a0af6bad72895f1778c06ad21fc755 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 17:21:31 -0400 Subject: [PATCH 36/39] fix(app, components): fix labware map view selection ODD (#15930) Render proper modal depending on if labware is top element of stack or loaded directly on deck. Refactors to use the same state variable to keep track of the selected labware, and determines which modal to show depending on if the selected labware was loaded onto a module/adapter or directly onto the deck. --- .../SetupLabware/LabwareStackModal.tsx | 31 ++++++++++++-- .../SetupLabware/SetupLabwareMap.tsx | 1 + .../organisms/ProtocolSetupLabware/index.tsx | 41 +++++++------------ .../Labware/LabwareStackRender.tsx | 7 ++-- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx index 83fdf1d9260..80bd38a3255 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -23,9 +23,17 @@ import { getLocationInfoNames } from '../utils/getLocationInfoNames' import { getSlotLabwareDefinition } from '../utils/getSlotLabwareDefinition' import { Divider } from '../../../../atoms/structure' import { getModuleImage } from '../SetupModuleAndDeck/utils' -import { getModuleDisplayName } from '@opentrons/shared-data' +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; @@ -36,12 +44,13 @@ interface LabwareStackModalProps { labwareIdTop: string runId: string closeModal: () => void + robotType?: RobotType } export const LabwareStackModal = ( props: LabwareStackModalProps ): JSX.Element | null => { - const { labwareIdTop, runId, closeModal } = props + const { labwareIdTop, runId, closeModal, robotType = FLEX_ROBOT_TYPE } = props const { t } = useTranslation('protocol_setup') const isOnDevice = useSelector(getIsOnDevice) const protocolData = useMostRecentCompletedAnalysis(runId) @@ -60,6 +69,14 @@ export const LabwareStackModal = ( 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 = ( @@ -80,7 +97,9 @@ export const LabwareStackModal = ( header={{ title: ( - + ), @@ -156,7 +175,11 @@ export const LabwareStackModal = ( onClose={closeModal} closeOnOutsideClick title={t('stacked_slot')} - titleElement1={} + titleElement1={ + + } titleElement2={} childrenPadding={0} marginLeft="0" diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 9a3c04fdc5f..8a35d8d203e 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -180,6 +180,7 @@ export function SetupLabwareMap({ closeModal={() => { setLabwareStackDetailsLabwareId(null) }} + robotType={robotType} /> )} diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 919d887f491..cbc8d363b4b 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -98,6 +98,7 @@ export function ProtocolSetupLabware({ | (LabwareDefinition2 & { location: LabwareLocation nickName: string | null + id: string }) | null >(null) @@ -143,13 +144,21 @@ export function ProtocolSetupLabware({ ...labwareDef, location: foundLabware.location, nickName: nickName ?? null, + id: labwareId, }) setShowLabwareDetailsModal(true) } } + const selectedLabwareIsTopOfStack = mostRecentAnalysis?.commands.some( + command => + command.commandType === 'loadLabware' && + command.result?.labwareId === selectedLabware?.id && + typeof command.params.location === 'object' && + ('moduleId' in command.params.location || + 'labwareId' in command.params.location) + ) let location: JSX.Element | string | null = null - let topLabwareId: string | null = null if ( selectedLabware != null && typeof selectedLabware.location === 'object' && @@ -178,17 +187,6 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { - topLabwareId = - mostRecentAnalysis?.commands.find( - (command): command is LoadLabwareRunTimeCommand => { - return ( - command.commandType === 'loadLabware' && - typeof command.params.location === 'object' && - 'moduleId' in command.params.location && - command.params.location.moduleId === matchedModule.moduleId - ) - } - )?.result?.labwareId ?? null location = } } else if ( @@ -203,17 +201,6 @@ export function ProtocolSetupLabware({ command.result?.labwareId === adapterId )?.params.location if (adapterLocation != null && adapterLocation !== 'offDeck') { - topLabwareId = - mostRecentAnalysis?.commands.find( - (command): command is LoadLabwareRunTimeCommand => { - return ( - command.commandType === 'loadLabware' && - typeof command.params.location === 'object' && - 'labwareId' in command.params.location && - command.params.location.labwareId === adapterId - ) - } - )?.result?.labwareId ?? null if ('slotName' in adapterLocation) { location = } else if ('moduleId' in adapterLocation) { @@ -232,7 +219,7 @@ export function ProtocolSetupLabware({ {createPortal( <> {showLabwareDetailsModal && - topLabwareId == null && + !selectedLabwareIsTopOfStack && selectedLabware != null ? ( { @@ -357,9 +344,11 @@ export function ProtocolSetupLabware({ })} )} - {showLabwareDetailsModal && topLabwareId != null ? ( + {showLabwareDetailsModal && + selectedLabware != null && + selectedLabwareIsTopOfStack ? ( { setSelectedLabware(null) diff --git a/components/src/hardware-sim/Labware/LabwareStackRender.tsx b/components/src/hardware-sim/Labware/LabwareStackRender.tsx index d9184e327f0..6b1b9aec35d 100644 --- a/components/src/hardware-sim/Labware/LabwareStackRender.tsx +++ b/components/src/hardware-sim/Labware/LabwareStackRender.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { WellLabels, StaticLabware } from './labwareInternals' -import { LabwareAdapter } from './LabwareAdapter' +import { LabwareAdapter, labwareAdapterLoadNames } from './LabwareAdapter' import { COLORS } from '../../helix-design-system' import { Svg } from '../..' @@ -8,7 +8,6 @@ import type { LabwareDefinition2 } from '@opentrons/shared-data' import type { HighlightedWellLabels } from './labwareInternals/types' import type { LabwareAdapterLoadName } from './LabwareAdapter' import type { WellLabelOption } from '../..' - const HIGHLIGHT_COLOR = COLORS.blue30 const STROKE_WIDTH = 1 const SKEW_ANGLE_DEGREES = 30 @@ -87,7 +86,9 @@ export const LabwareStackRender = ( definitionBottom.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' ) { const { xDimension, yDimension } = definitionTop.dimensions - const isTopAdapter = definitionTop.metadata.displayCategory === 'adapter' + const isTopAdapter = labwareAdapterLoadNames.includes( + definitionTop.parameters.loadName + ) return isTopAdapter ? ( // adapter render From b91340922df6d0d66a6e1258972b435c8143d4a3 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 8 Aug 2024 09:30:43 -0400 Subject: [PATCH 37/39] fix(app): styles and copy in error recovery error (#15931) Guess we never got around to updating these. They're correct for [the release file](https://www.figma.com/design/Rwdt9R0aERFC55oTLDTlqY/8.0-September-Release-File?node-id=218-35940&t=wOA3qgaCJdi9T3ub-4) Closes RQA-2919 --- .../localization/en/error_recovery.json | 2 +- .../ErrorRecoveryFlows/RecoveryError.tsx | 24 +++++++++++++------ .../__tests__/RecoveryError.test.tsx | 12 +++++++--- 3 files changed, 27 insertions(+), 11 deletions(-) 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/organisms/ErrorRecoveryFlows/RecoveryError.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx index e38647927db..370b80174a6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx @@ -6,7 +6,7 @@ import { DIRECTION_COLUMN, Flex, Icon, - LegacyStyledText, + StyledText, SPACING, ALIGN_CENTER, JUSTIFY_END, @@ -61,7 +61,7 @@ export function ErrorRecoveryFlowError({ @@ -103,13 +103,13 @@ export function RecoveryDropTipFlowErrors({ const buildSubTitle = (): string => { switch (step) { case ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_GENERAL_ERROR: - return t('return_to_the_menu') + return t('next_try_another_action') case ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_TIP_DROP_FAILED: - return t('return_to_the_menu') + return t('next_try_another_action') case ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_BLOWOUT_FAILED: return t('you_can_still_drop_tips') default: - return t('return_to_the_menu') + return t('next_try_another_action') } } @@ -189,8 +189,18 @@ export function ErrorContent({ alignItems={ALIGN_CENTER} textAlign={ALIGN_CENTER} > - {title} - {subTitle} + + {title} + + + {subTitle} + diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx index 5e95f0c65a5..d3b0d4dd629 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx @@ -50,7 +50,9 @@ describe('RecoveryError', () => { expect(screen.getByText('Retry step failed')).toBeInTheDocument() expect( - screen.getByText('Return to the menu to choose how to proceed.') + screen.getByText( + 'Next, you can try another recovery action or cancel the run.' + ) ).toBeInTheDocument() expect(screen.getByText('Back to menu')).toBeInTheDocument() }) @@ -62,7 +64,9 @@ describe('RecoveryError', () => { expect(screen.getByText('Retry step failed')).toBeInTheDocument() expect( - screen.getByText('Return to the menu to choose how to proceed.') + screen.getByText( + 'Next, you can try another recovery action or cancel the run.' + ) ).toBeInTheDocument() expect(screen.getByText('Return to menu')).toBeInTheDocument() }) @@ -88,7 +92,9 @@ describe('RecoveryError', () => { expect(screen.getByText('Tip drop failed')).toBeInTheDocument() expect( - screen.getByText('Return to the menu to choose how to proceed.') + screen.getByText( + 'Next, you can try another recovery action or cancel the run.' + ) ).toBeInTheDocument() expect(screen.getByText('Return to menu')).toBeInTheDocument() }) From 1e6576a8a17349446a45571ba8d3af1d9c9bfaa8 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Thu, 8 Aug 2024 13:05:02 -0400 Subject: [PATCH 38/39] fix(shared-data): Partial tip 96ch single nozzle pickup press distance value adjustment (#15934) Covers RQA-2920 Adjust the press distance from 10.5mm to 12mm to ensure success when picking up tips in single tip configuration. --- .../general/ninety_six_channel/p1000/3_5.json | 32 +++++++++---------- .../general/ninety_six_channel/p1000/3_6.json | 32 +++++++++---------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json index 9c6df88b575..0ac9da8be5a 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json @@ -147,7 +147,7 @@ "SingleA1": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -172,7 +172,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -189,7 +189,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -206,7 +206,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -225,7 +225,7 @@ "SingleH1": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -250,7 +250,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -267,7 +267,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -284,7 +284,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -303,7 +303,7 @@ "SingleA12": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -328,7 +328,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -345,7 +345,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -362,7 +362,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -381,7 +381,7 @@ "SingleH12": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -406,7 +406,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -423,7 +423,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -440,7 +440,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json index 7bcfb04e4f0..43d4412d0d6 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json @@ -147,7 +147,7 @@ "SingleA1": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -172,7 +172,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -189,7 +189,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -206,7 +206,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -225,7 +225,7 @@ "SingleH1": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -250,7 +250,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -267,7 +267,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -284,7 +284,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -303,7 +303,7 @@ "SingleA12": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -328,7 +328,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -345,7 +345,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -362,7 +362,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -381,7 +381,7 @@ "SingleH12": { "default": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -406,7 +406,7 @@ }, "t1000": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -423,7 +423,7 @@ }, "t200": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { @@ -440,7 +440,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.5, + "distance": 12, "current": 0.4, "tipOverlaps": { "v0": { From 284d1f83bbdd3f06a5da0dddd168532117cd347b Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Thu, 8 Aug 2024 14:12:50 -0400 Subject: [PATCH 39/39] fix(app): Fix blowout location options text (#15936) --- .../QuickTransferAdvancedSettings/BlowOut.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx index abab3a9ae34..2823f95a146 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx @@ -62,13 +62,13 @@ export const useBlowOutLocationOptions = ( if (transferType !== 'distribute') { blowOutLocationItems.push({ location: 'dest_well', - description: t('blow_out_source_well'), + description: t('blow_out_destination_well'), }) } if (transferType !== 'consolidate') { blowOutLocationItems.push({ location: 'source_well', - description: t('blow_out_destination_well'), + description: t('blow_out_source_well'), }) } trashLocations.forEach(location => {