diff --git a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py index 8e00114747a..75b73b8f16b 100644 --- a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -363,3 +363,4 @@ def run( folder_name = args.folder_name[0] google_sheet_name = args.google_sheet_name[0] email = args.email[0] + run(storage_directory, folder_name, google_sheet_name, email) diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index d740518c7ac..9fd9f0e7d71 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -110,6 +110,7 @@ def identify_labware_ids( file_results: Dict[str, Any], labware_name: Optional[str] ) -> List[str]: """Determine what type of labware is being picked up.""" + list_of_labware_ids: List[str] = [] if labware_name: labwares = file_results.get("labware", "") list_of_labware_ids = [] @@ -341,8 +342,9 @@ def hs_commands(file_results: Dict[str, Any]) -> Dict[str, float]: ) if temp_time is not None and deactivate_time is None: # If heater shaker module is not deactivated, protocol completedAt time stamp used. + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - temp_time).total_seconds() hs_temps[hs_temp] = hs_temps.get(hs_temp, 0.0) + temp_duration @@ -389,8 +391,9 @@ def temperature_module_commands(file_results: Dict[str, Any]) -> Dict[str, Any]: tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration if temp_time is not None and deactivate_time is None: # If temperature module is not deactivated, protocol completedAt time stamp used. + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - temp_time).total_seconds() tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration @@ -473,15 +476,17 @@ def thermocycler_commands(file_results: Dict[str, Any]) -> Dict[str, float]: block_temps[block_temp] = block_temps.get(block_temp, 0.0) + block_time if block_on_time is not None and block_off_time is None: # If thermocycler block not deactivated protocol completedAt time stamp used + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - block_on_time).total_seconds() - block_temps[block_temp] = block_temps.get(block_temp, 0.0) + temp_duration + if lid_on_time is not None and lid_off_time is None: # If thermocycler lid not deactivated protocol completedAt time stamp used + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - lid_on_time).total_seconds() lid_temps[lid_temp] = block_temps.get(lid_temp, 0.0) + temp_duration diff --git a/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py index 513860baa9b..23575165eff 100644 --- a/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py +++ b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py @@ -5,9 +5,9 @@ from pathlib import Path -def run(file_to_simulate: str) -> None: +def run(file_to_simulate: Path) -> None: """Simulate protocol and raise errors.""" - protocol_name = Path(file_to_simulate).stem + protocol_name = file_to_simulate.stem try: simulation_metrics.main(file_to_simulate, False) except Exception: @@ -29,6 +29,6 @@ def run(file_to_simulate: str) -> None: if file.endswith(".py"): # If it's a Python file if file in exclude: continue - file_path = os.path.join(root, file) - print(f"Simulating protocol: {file_path}") + file_path = Path(os.path.join(root, file)) + print(f"Simulating protocol: {file_path.stem}") run(file_path) diff --git a/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py index 418c1e1aacd..9d21109f37e 100644 --- a/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py +++ b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py @@ -241,6 +241,7 @@ def parse_results_volume( "Right Pipette Total Aspirates", "Right Pipette Total Dispenses", "Gripper Pick Ups", + "Gripper Pick Ups of opentrons_tough_pcr_auto_sealing_lid", "Total Liquid Probes", "Average Liquid Probe Time (sec)", ] @@ -302,6 +303,7 @@ def parse_results_volume( total_time_row.append(str(end_time - start_time)) for metric in metrics: + print(f"Dictionary: {metric}\n\n") for cmd in metric.keys(): values_row.append(str(metric[cmd])) return ( @@ -320,15 +322,16 @@ def parse_results_volume( def main( - protocol_file_path_name: str, + protocol_file_path: Path, save: bool, storage_directory: str = os.curdir, google_sheet_name: str = "", + parameters: List[str] = [], ) -> None: """Main module control.""" sys.exit = mock_exit # Replace sys.exit with the mock function # Read file path from arguments - protocol_file_path = Path(protocol_file_path_name) + # protocol_file_path = Path(protocol_file_path_name) protocol_name = protocol_file_path.stem print("Simulating", protocol_name) file_date = datetime.now() @@ -344,26 +347,58 @@ def main( ) json_file_output = open(json_file_path, "wb+") # log_output_file = f"{protocol_name}_log" - ctx.invoke( - analyze, - files=[protocol_file_path], - json_output=json_file_output, - human_json_output=None, - log_output=error_output, - log_level="ERROR", - check=False, - ) + if parameters: + print(f"Parameter: {parameters[0]}\n") + csv_params = {} + csv_params["parameters_csv"] = parameters[0] + rtp_json = json.dumps(csv_params) + ctx.invoke( + analyze, + files=[protocol_file_path], + rtp_files=rtp_json, + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False, + ) + + else: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False, + ) json_file_output.close() else: - ctx.invoke( - analyze, - files=[protocol_file_path], - json_output=None, - human_json_output=None, - log_output=error_output, - log_level="ERROR", - check=True, - ) + if parameters: + csv_params = {} + csv_params["parameters_csv"] = parameters[0] + rtp_json = json.dumps(csv_params) + ctx.invoke( + analyze, + files=[protocol_file_path], + rtp_files=rtp_json, + json_output=None, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=True, + ) + else: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=None, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=True, + ) except SystemExit as e: print(f"SystemExit caught with code: {e}") @@ -395,6 +430,7 @@ def main( credentials_path, google_sheet_name, 0 ) google_sheet.write_to_row([]) + for row in parse_results_volume( json_file_path, protocol_name, @@ -428,13 +464,15 @@ def main( "protocol_file_path", metavar="PROTOCOL_FILE_PATH", type=str, - nargs=1, + nargs="*", help="Path to protocol file", ) args = parser.parse_args() storage_directory = args.storage_directory[0] sheet_name = args.sheet_name[0] protocol_file_path: str = args.protocol_file_path[0] + parameters: List[str] = args.protocol_file_path[1:] + print(parameters) SETUP = True while SETUP: print( @@ -445,7 +483,7 @@ def main( choice = "" while not choice: choice = input( - "Remove air_gap commands to ensure accurate results? (Y/N): " + "Remove air_gap commands to ensure accurate results: (continue)? (Y/N): " ) if choice.upper() == "Y": SETUP = False @@ -462,11 +500,20 @@ def main( # Change api level if CLEAN_PROTOCOL: set_api_level(protocol_file_path) - main( - protocol_file_path, - True, - storage_directory, - sheet_name, - ) + if parameters: + main( + Path(protocol_file_path), + True, + storage_directory, + sheet_name, + parameters=parameters, + ) + else: + main( + protocol_file_path=Path(protocol_file_path), + save=True, + storage_directory=storage_directory, + google_sheet_name=sheet_name, + ) else: sys.exit(0) diff --git a/api/src/opentrons/protocol_api/_liquid.py b/api/src/opentrons/protocol_api/_liquid.py index 0cb104099eb..12c9a140ce3 100644 --- a/api/src/opentrons/protocol_api/_liquid.py +++ b/api/src/opentrons/protocol_api/_liquid.py @@ -8,12 +8,8 @@ ) from ._liquid_properties import ( - AspirateProperties, - SingleDispenseProperties, - MultiDispenseProperties, - build_aspirate_properties, - build_single_dispense_properties, - build_multi_dispense_properties, + TransferProperties, + build_transfer_properties, ) @@ -35,30 +31,6 @@ class Liquid: display_color: Optional[str] -# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties -# and have validation on value updates with user-facing error messages -@dataclass -class TransferProperties: - _aspirate: AspirateProperties - _dispense: SingleDispenseProperties - _multi_dispense: Optional[MultiDispenseProperties] - - @property - def aspirate(self) -> AspirateProperties: - """Aspirate properties.""" - return self._aspirate - - @property - def dispense(self) -> SingleDispenseProperties: - """Single dispense properties.""" - return self._dispense - - @property - def multi_dispense(self) -> Optional[MultiDispenseProperties]: - """Multi dispense properties.""" - return self._multi_dispense - - @dataclass class LiquidClass: """A data class that contains properties of a specific class of liquids.""" @@ -75,13 +47,7 @@ def create(cls, liquid_class_definition: LiquidClassSchemaV1) -> "LiquidClass": for by_pipette in liquid_class_definition.byPipette: tip_settings: Dict[str, TransferProperties] = {} for tip_type in by_pipette.byTipType: - tip_settings[tip_type.tiprack] = TransferProperties( - _aspirate=build_aspirate_properties(tip_type.aspirate), - _dispense=build_single_dispense_properties(tip_type.singleDispense), - _multi_dispense=build_multi_dispense_properties( - tip_type.multiDispense - ), - ) + tip_settings[tip_type.tiprack] = build_transfer_properties(tip_type) by_pipette_settings[by_pipette.pipetteModel] = tip_settings return cls( diff --git a/api/src/opentrons/protocol_api/_liquid_properties.py b/api/src/opentrons/protocol_api/_liquid_properties.py index f0dd0adfe92..8bd7aa6cfd8 100644 --- a/api/src/opentrons/protocol_api/_liquid_properties.py +++ b/api/src/opentrons/protocol_api/_liquid_properties.py @@ -9,6 +9,7 @@ TouchTipProperties as SharedDataTouchTipProperties, MixProperties as SharedDataMixProperties, BlowoutProperties as SharedDataBlowoutProperties, + ByTipTypeSetting as SharedByTipTypeSetting, Submerge as SharedDataSubmerge, RetractAspirate as SharedDataRetractAspirate, RetractDispense as SharedDataRetractDispense, @@ -361,6 +362,30 @@ def disposal_by_volume(self) -> LiquidHandlingPropertyByVolume: return self._disposal_by_volume +# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties +# and have validation on value updates with user-facing error messages +@dataclass +class TransferProperties: + _aspirate: AspirateProperties + _dispense: SingleDispenseProperties + _multi_dispense: Optional[MultiDispenseProperties] + + @property + def aspirate(self) -> AspirateProperties: + """Aspirate properties.""" + return self._aspirate + + @property + def dispense(self) -> SingleDispenseProperties: + """Single dispense properties.""" + return self._dispense + + @property + def multi_dispense(self) -> Optional[MultiDispenseProperties]: + """Multi dispense properties.""" + return self._multi_dispense + + def _build_delay_properties( delay_properties: SharedDataDelayProperties, ) -> DelayProperties: @@ -501,3 +526,15 @@ def build_multi_dispense_properties( _disposal_by_volume=multi_dispense_properties.disposalByVolume, _delay=_build_delay_properties(multi_dispense_properties.delay), ) + + +def build_transfer_properties( + by_tip_type_setting: SharedByTipTypeSetting, +) -> TransferProperties: + return TransferProperties( + _aspirate=build_aspirate_properties(by_tip_type_setting.aspirate), + _dispense=build_single_dispense_properties(by_tip_type_setting.singleDispense), + _multi_dispense=build_multi_dispense_properties( + by_tip_type_setting.multiDispense + ), + ) diff --git a/api/src/opentrons/protocol_api/core/engine/well.py b/api/src/opentrons/protocol_api/core/engine/well.py index 6743a8a39c5..dba1dc6c840 100644 --- a/api/src/opentrons/protocol_api/core/engine/well.py +++ b/api/src/opentrons/protocol_api/core/engine/well.py @@ -130,7 +130,10 @@ def load_liquid( liquid: Liquid, volume: float, ) -> None: - """Load liquid into a well.""" + """Load liquid into a well. + + If the well is known to be empty, use ``load_empty()`` instead of calling this with a 0.0 volume. + """ self._engine_client.execute_command( cmd.LoadLiquidParams( labwareId=self._labware_id, @@ -139,6 +142,22 @@ def load_liquid( ) ) + def load_empty( + self, + ) -> None: + """Inform the system that a well is known to be empty. + + This should be done early in the protocol, at the same time as a load_liquid command might + be used. + """ + self._engine_client.execute_command( + cmd.LoadLiquidParams( + labwareId=self._labware_id, + liquidId="EMPTY", + volumeByWell={self._name: 0.0}, + ) + ) + def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" well_size = self._engine_client.state.labware.get_well_size( diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py index a88dd2eee80..891f0f1b681 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py @@ -114,6 +114,10 @@ def load_liquid( """Load liquid into a well.""" raise APIVersionError(api_element="Loading a liquid") + def load_empty(self) -> None: + """Mark a well as empty.""" + assert False, "load_empty only supported on engine core" + def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" return self._geometry.from_center_cartesian(x, y, z) diff --git a/api/src/opentrons/protocol_api/core/well.py b/api/src/opentrons/protocol_api/core/well.py index bd58963a59c..24489bb04e7 100644 --- a/api/src/opentrons/protocol_api/core/well.py +++ b/api/src/opentrons/protocol_api/core/well.py @@ -79,6 +79,10 @@ def load_liquid( ) -> None: """Load liquid into a well.""" + @abstractmethod + def load_empty(self) -> None: + """Mark a well as containing no liquid.""" + @abstractmethod def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 0e8a17d07d3..825cc19668a 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -280,12 +280,20 @@ def load_liquid(self, liquid: Liquid, volume: float) -> None: :param Liquid liquid: The liquid to load into the well. :param float volume: The volume of liquid to load, in µL. + + .. note:: + In API version 2.22 and later, use :py:meth:`~.Well.load_empty()` to mark a well as empty at the beginning of a protocol, rather than using this method with ``volume=0``. """ self._core.load_liquid( liquid=liquid, volume=volume, ) + @requires_version(2, 22) + def load_empty(self) -> None: + """Mark a well as empty.""" + self._core.load_empty() + def _from_center_cartesian(self, x: float, y: float, z: float) -> Point: """ Private version of from_center_cartesian. Present only for backward diff --git a/api/src/opentrons/protocol_engine/commands/load_liquid.py b/api/src/opentrons/protocol_engine/commands/load_liquid.py index 5dd4737410e..f6aa037fa01 100644 --- a/api/src/opentrons/protocol_engine/commands/load_liquid.py +++ b/api/src/opentrons/protocol_engine/commands/load_liquid.py @@ -5,6 +5,8 @@ from typing_extensions import Literal from opentrons.protocol_engine.state.update_types import StateUpdate +from opentrons.protocol_engine.types import LiquidId +from opentrons.protocol_engine.errors import InvalidLiquidError from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence @@ -19,9 +21,9 @@ class LoadLiquidParams(BaseModel): """Payload required to load a liquid into a well.""" - liquidId: str = Field( + liquidId: LiquidId = Field( ..., - description="Unique identifier of the liquid to load.", + description="Unique identifier of the liquid to load. If this is the sentinel value EMPTY, all values of volumeByWell must be 0.", ) labwareId: str = Field( ..., @@ -29,7 +31,7 @@ class LoadLiquidParams(BaseModel): ) volumeByWell: Dict[str, float] = Field( ..., - description="Volume of liquid, in µL, loaded into each well by name, in this labware.", + description="Volume of liquid, in µL, loaded into each well by name, in this labware. If the liquid id is the sentinel value EMPTY, all volumes must be 0.", ) @@ -57,6 +59,12 @@ async def execute(self, params: LoadLiquidParams) -> SuccessData[LoadLiquidResul self._state_view.labware.validate_liquid_allowed_in_labware( labware_id=params.labwareId, wells=params.volumeByWell ) + if params.liquidId == "EMPTY": + for well_name, volume in params.volumeByWell.items(): + if volume != 0.0: + raise InvalidLiquidError( + 'loadLiquid commands that specify the special liquid "EMPTY" must set volume to be 0.0, but the volume for {well_name} is {volume}' + ) state_update = StateUpdate() state_update.set_liquid_loaded( diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index b25dfdb2d0e..e9f1acddeed 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -77,6 +77,7 @@ OperationLocationNotInWellError, InvalidDispenseVolumeError, StorageLimitReachedError, + InvalidLiquidError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -137,6 +138,7 @@ "InvalidTargetSpeedError", "InvalidBlockVolumeError", "InvalidHoldTimeError", + "InvalidLiquidError", "CannotPerformModuleAction", "ResumeFromRecoveryNotAllowedError", "PauseNotAllowedError", diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 12f45f4936d..36b0d2ccbef 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -244,6 +244,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class InvalidLiquidError(ProtocolEngineError): + """Raised when attempting to add a liquid with an invalid property.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an InvalidLiquidError.""" + super().__init__(ErrorCodes.INVALID_PROTOCOL_DATA, message, details, wrapping) + + class LabwareDefinitionDoesNotExistError(ProtocolEngineError): """Raised when referencing a labware definition that does not exist.""" diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index ced32b20cc3..574c3d076f9 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -566,9 +566,12 @@ def add_liquid( description=(description or ""), displayColor=color, ) + validated_liquid = self._state_store.liquid.validate_liquid_allowed( + liquid=liquid + ) - self._action_dispatcher.dispatch(AddLiquidAction(liquid=liquid)) - return liquid + self._action_dispatcher.dispatch(AddLiquidAction(liquid=validated_liquid)) + return validated_liquid def add_addressable_area(self, addressable_area_name: str) -> None: """Add an addressable area to state.""" diff --git a/api/src/opentrons/protocol_engine/state/liquids.py b/api/src/opentrons/protocol_engine/state/liquids.py index 9394e4261b1..775223c6a60 100644 --- a/api/src/opentrons/protocol_engine/state/liquids.py +++ b/api/src/opentrons/protocol_engine/state/liquids.py @@ -1,11 +1,11 @@ """Basic liquid data state and store.""" from dataclasses import dataclass from typing import Dict, List -from opentrons.protocol_engine.types import Liquid +from opentrons.protocol_engine.types import Liquid, LiquidId from ._abstract_store import HasState, HandlesActions from ..actions import Action, AddLiquidAction -from ..errors import LiquidDoesNotExistError +from ..errors import LiquidDoesNotExistError, InvalidLiquidError @dataclass @@ -51,11 +51,23 @@ def get_all(self) -> List[Liquid]: """Get all protocol liquids.""" return list(self._state.liquids_by_id.values()) - def validate_liquid_id(self, liquid_id: str) -> str: + def validate_liquid_id(self, liquid_id: LiquidId) -> LiquidId: """Check if liquid_id exists in liquids.""" + is_empty = liquid_id == "EMPTY" + if is_empty: + return liquid_id has_liquid = liquid_id in self._state.liquids_by_id if not has_liquid: raise LiquidDoesNotExistError( f"Supplied liquidId: {liquid_id} does not exist in the loaded liquids." ) return liquid_id + + def validate_liquid_allowed(self, liquid: Liquid) -> Liquid: + """Validate that a liquid is legal to load.""" + is_empty = liquid.id == "EMPTY" + if is_empty: + raise InvalidLiquidError( + message='Protocols may not define a liquid with the special id "EMPTY".' + ) + return liquid diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 780b02d2129..5aa4c8c26e9 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -828,6 +828,10 @@ def _color_is_a_valid_hex(cls, v: str) -> str: return v +EmptyLiquidId = Literal["EMPTY"] +LiquidId = str | EmptyLiquidId + + class Liquid(BaseModel): """Payload required to create a liquid.""" diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 6520bb912f6..f9a59799d9d 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -36,7 +36,7 @@ def _host_config(level_value: int) -> Dict[str, Any]: "class": "logging.handlers.RotatingFileHandler", "formatter": "basic", "filename": serial_log_filename, - "maxBytes": 5000000, + "maxBytes": 1000000, "level": logging.DEBUG, "backupCount": 3, }, diff --git a/api/tests/opentrons/protocol_api/test_well.py b/api/tests/opentrons/protocol_api/test_well.py index ef1eed84c62..b4817567dde 100644 --- a/api/tests/opentrons/protocol_api/test_well.py +++ b/api/tests/opentrons/protocol_api/test_well.py @@ -8,6 +8,8 @@ from opentrons.protocol_api._liquid import Liquid from opentrons.types import Point, Location +from . import versions_at_or_above + @pytest.fixture def mock_well_core(decoy: Decoy) -> WellCore: @@ -140,6 +142,13 @@ def test_load_liquid(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> N ) +@pytest.mark.parametrize("api_version", versions_at_or_above(APIVersion(2, 22))) +def test_load_empty(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None: + """It should mark a location as empty.""" + subject.load_empty() + decoy.verify(mock_well_core.load_empty(), times=1) + + def test_diameter(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None: """It should get the diameter from the core.""" decoy.when(mock_well_core.diameter).then_return(12.3) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py index dbc584ae2a3..6bd61061f3c 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py @@ -9,6 +9,7 @@ LoadLiquidImplementation, LoadLiquidParams, ) +from opentrons.protocol_engine.errors import InvalidLiquidError from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.protocol_engine.state import update_types @@ -64,3 +65,37 @@ async def test_load_liquid_implementation( "labware-id", {"A1": 30.0, "B2": 100.0} ) ) + + +async def test_load_empty_liquid_requires_zero_volume( + decoy: Decoy, + subject: LoadLiquidImplementation, + mock_state_view: StateView, + model_utils: ModelUtils, +) -> None: + """Test that loadLiquid requires empty liquids to have 0 volume.""" + data = LoadLiquidParams( + labwareId="labware-id", liquidId="EMPTY", volumeByWell={"A1": 1.0} + ) + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + + with pytest.raises(InvalidLiquidError): + await subject.execute(data) + + decoy.verify(mock_state_view.liquid.validate_liquid_id("EMPTY")) + + data2 = LoadLiquidParams( + labwareId="labware-id", liquidId="EMPTY", volumeByWell={"A1": 0.0} + ) + result = await subject.execute(data2) + assert result == SuccessData( + public=LoadLiquidResult(), + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id="labware-id", + volumes=data2.volumeByWell, + last_loaded=timestamp, + ) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_liquid_view.py b/api/tests/opentrons/protocol_engine/state/test_liquid_view.py index f3424932b0e..db1e6f274a1 100644 --- a/api/tests/opentrons/protocol_engine/state/test_liquid_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_liquid_view.py @@ -3,7 +3,7 @@ from opentrons.protocol_engine.state.liquids import LiquidState, LiquidView from opentrons.protocol_engine import Liquid -from opentrons.protocol_engine.errors import LiquidDoesNotExistError +from opentrons.protocol_engine.errors import LiquidDoesNotExistError, InvalidLiquidError @pytest.fixture @@ -33,3 +33,22 @@ def test_has_liquid(subject: LiquidView) -> None: with pytest.raises(LiquidDoesNotExistError): subject.validate_liquid_id("no-id") + + +def test_validate_liquid_prevents_empty(subject: LiquidView) -> None: + """It should not allow loading a liquid with the special id EMPTY.""" + with pytest.raises(InvalidLiquidError): + subject.validate_liquid_allowed( + Liquid(id="EMPTY", displayName="empty", description="nothing") + ) + + +def test_validate_liquid_allows_non_empty(subject: LiquidView) -> None: + """It should allow a valid liquid.""" + valid_liquid = Liquid( + id="some-id", + displayName="some-display-name", + description="some-description", + displayColor=None, + ) + assert subject.validate_liquid_allowed(valid_liquid) == valid_liquid diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index ac83e987153..bc581114ab2 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -1133,21 +1133,18 @@ def test_add_liquid( decoy: Decoy, action_dispatcher: ActionDispatcher, subject: ProtocolEngine, + state_store: StateStore, ) -> None: """It should dispatch an AddLiquidAction action.""" + liquid_obj = Liquid(id="water-id", displayName="water", description="water desc") + decoy.when( + state_store.liquid.validate_liquid_allowed(liquid=liquid_obj) + ).then_return(liquid_obj) subject.add_liquid( id="water-id", name="water", description="water desc", color=None ) - decoy.verify( - action_dispatcher.dispatch( - AddLiquidAction( - liquid=Liquid( - id="water-id", displayName="water", description="water desc" - ) - ) - ) - ) + decoy.verify(action_dispatcher.dispatch(AddLiquidAction(liquid=liquid_obj))) async def test_use_attached_temp_and_mag_modules( diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index 57667e6cca9..c6d8eff68ea 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -2,7 +2,12 @@ import * as React from 'react' import styled, { css } from 'styled-components' import { Flex } from '../../primitives' -import { ALIGN_CENTER, DIRECTION_COLUMN, TEXT_ALIGN_RIGHT } from '../../styles' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + TEXT_ALIGN_RIGHT, +} from '../../styles' import { BORDERS, COLORS } from '../../helix-design-system' import { Icon } from '../../icons' import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' @@ -249,7 +254,11 @@ export const InputField = React.forwardRef( > {title != null ? ( - + {title !== null ? ( - + browse your files", "example": "For example prompts, click the buttons in the left panel.", + "file_length_error": "The length of the file contents is 0. Please upload a file with content.", "exit": "Exit", "exit_confirmation_title": "Are you sure you want to exit?", "exit_confirmation_body": "Exiting now will discard your progress.", @@ -26,6 +29,10 @@ "login": "Login", "logout": "Logout", "make_sure_your_prompt": "Write a prompt in a natural language for OpentronsAI to generate a protocol using the Opentrons Python Protocol API v2. The better the prompt, the better the quality of the protocol produced by OpentronsAI.", + "modify_intro": "Modify the following Python code using the Opentrons Python Protocol API v2. Ensure that the new labware and pipettes are compatible with the Flex robot. Ensure that you perform the correct Type of Update use the Details of Changes.\n\n", + "modify_python_code": "Original Python Code:\n", + "modify_type_of_update": "Type of update:\n- ", + "modify_details_of_change": "Details of Changes:\n- ", "modules_and_adapters": "Modules and adapters: Specify the modules and labware adapters required by your protocol.", "notes": "A few important things to note:", "opentrons": "Opentrons", @@ -35,6 +42,9 @@ "pcr": "PCR", "pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.", "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement", + "protocol_file": "Protocol file", + "provide_details_of_changes": "Provide details of changes you want to make", + "python_file_type_error": "Python file type required", "reagent_transfer_flex": "Reagent Transfer (Flex)", "reagent_transfer": "Reagent Transfer", "reload_page": "To start over and create a new protocol, simply reload the page.", @@ -44,8 +54,11 @@ "side_panel_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", "side_panel_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", "simulate_description": "Once OpentronsAI has written your protocol, type `simulate` in the prompt box to try it out.", + "submit_prompt": "Submit prompt", "try_example_prompts": "Stuck? Try these example prompts to get started.", + "type_of_update": "Type of update", "type_your_prompt": "Type your prompt...", + "update_existing_protocol": "Update an existing protocol", "well_allocations": "Well allocations: Describe where liquids should go in labware.", "what_if_you": "What if you don’t provide all of those pieces of information? OpentronsAI asks you to provide it!", "what_typeof_protocol": "What type of protocol do you need?", diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx index 4eaa840dbcb..dd39d415acc 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx @@ -18,6 +18,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + OVERFLOW_AUTO, } from '@opentrons/components' import type { ChatData } from '../../resources/types' @@ -63,6 +64,7 @@ export function ChatDisplay({ chat, chatId }: ChatDisplayProps): JSX.Element { data-testid={`ChatDisplay_from_${isUser ? 'user' : 'backend'}`} borderRadius={BORDERS.borderRadius12} width="100%" + overflowY={OVERFLOW_AUTO} flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing16} position={POSITION_RELATIVE} diff --git a/opentrons-ai-client/src/molecules/FileUpload/index.tsx b/opentrons-ai-client/src/molecules/FileUpload/index.tsx new file mode 100644 index 00000000000..551c3d0bd05 --- /dev/null +++ b/opentrons-ai-client/src/molecules/FileUpload/index.tsx @@ -0,0 +1,74 @@ +import { css } from 'styled-components' + +import { + ALIGN_CENTER, + BORDERS, + Btn, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + SPACING, + LegacyStyledText, + truncateString, +} from '@opentrons/components' + +const FILE_UPLOAD_STYLE = css` + +&:hover > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}; +} +&:active > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}}; +} +` + +const FILE_UPLOAD_FOCUS_VISIBLE = css` + &:focus-visible { + border-radius: ${BORDERS.borderRadius4}; + box-shadow: 0 0 0 ${SPACING.spacing2} ${COLORS.blue50}; + } +` + +interface FileUploadProps { + file: File + fileError: string | null + handleClick: () => unknown +} + +export function FileUpload({ + file, + fileError, + handleClick, +}: FileUploadProps): JSX.Element { + return ( + + + + + {truncateString(file.name, 34, 19)} + + + + + {fileError != null ? ( + + {fileError} + + ) : null} + + ) +} diff --git a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx index d4c4cdf5f8d..70ee01560f4 100644 --- a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx +++ b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx @@ -15,7 +15,12 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { SendButton } from '../../atoms/SendButton' -import { chatDataAtom, chatHistoryAtom, tokenAtom } from '../../resources/atoms' +import { + chatDataAtom, + chatHistoryAtom, + chatPromptAtom, + tokenAtom, +} from '../../resources/atoms' import { useApiCall } from '../../resources/hooks' import { calcTextAreaHeight } from '../../resources/utils/utils' import { @@ -29,7 +34,8 @@ import type { ChatData } from '../../resources/types' export function InputPrompt(): JSX.Element { const { t } = useTranslation('protocol_generator') - const { register, watch, reset } = useFormContext() + const { register, watch, reset, setValue } = useFormContext() + const [chatPromptAtomValue] = useAtom(chatPromptAtom) const [, setChatData] = useAtom(chatDataAtom) const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom) const [token] = useAtom(tokenAtom) @@ -37,6 +43,10 @@ export function InputPrompt(): JSX.Element { const userPrompt = watch('userPrompt') ?? '' const { data, isLoading, callApi } = useApiCall() + useEffect(() => { + setValue('userPrompt', chatPromptAtomValue) + }, [chatPromptAtomValue, setValue]) + const handleClick = async (): Promise => { const userInput: ChatData = { role: 'user', diff --git a/opentrons-ai-client/src/molecules/UploadInput/index.tsx b/opentrons-ai-client/src/molecules/UploadInput/index.tsx new file mode 100644 index 00000000000..77dc5a2616d --- /dev/null +++ b/opentrons-ai-client/src/molecules/UploadInput/index.tsx @@ -0,0 +1,168 @@ +import * as React from 'react' +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + BORDERS, + COLORS, + CURSOR_POINTER, + DIRECTION_COLUMN, + DISPLAY_FLEX, + Flex, + Icon, + JUSTIFY_CENTER, + LegacyStyledText, + POSITION_FIXED, + PrimaryButton, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +const StyledLabel = styled.label` + display: ${DISPLAY_FLEX}; + cursor: ${CURSOR_POINTER}; + flex-direction: ${DIRECTION_COLUMN}; + align-items: ${ALIGN_CENTER}; + width: 100%; + padding: ${SPACING.spacing32}; + border: 2px dashed ${COLORS.grey30}; + border-radius: ${BORDERS.borderRadius4}; + text-align: ${TYPOGRAPHY.textAlignCenter}; + background-color: ${COLORS.white}; + + &:hover { + border: 2px dashed ${COLORS.blue50}; + } +` +const DRAG_OVER_STYLES = css` + border: 2px dashed ${COLORS.blue50}; +` + +const StyledInput = styled.input` + position: ${POSITION_FIXED}; + clip: rect(1px 1px 1px 1px); +` + +export interface UploadInputProps { + /** Callback function that is called when a file is uploaded. */ + onUpload: (file: File) => unknown + /** Optional callback function that is called when the upload button is clicked. */ + onClick?: () => void + /** Optional text for the upload button. If undefined, the button displays Upload */ + uploadButtonText?: string + /** Optional text or JSX element that is displayed above the upload button. */ + uploadText?: string | JSX.Element + /** Optional text or JSX element that is displayed in the drag and drop area. */ + dragAndDropText?: string | JSX.Element +} + +export function UploadInput(props: UploadInputProps): JSX.Element | null { + const { + dragAndDropText, + onClick, + onUpload, + uploadButtonText, + uploadText, + } = props + const { t } = useTranslation('protocol_info') + + const fileInput = React.useRef(null) + const [isFileOverDropZone, setIsFileOverDropZone] = React.useState( + false + ) + const [isHover, setIsHover] = React.useState(false) + const handleDrop: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + Array.from(e.dataTransfer.files).forEach(f => onUpload(f)) + setIsFileOverDropZone(false) + } + const handleDragEnter: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + } + const handleDragLeave: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + setIsFileOverDropZone(false) + setIsHover(false) + } + const handleDragOver: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + setIsFileOverDropZone(true) + setIsHover(true) + } + + const handleClick: React.MouseEventHandler = _event => { + onClick != null ? onClick() : fileInput.current?.click() + } + + const onChange: React.ChangeEventHandler = event => { + ;[...(event.target.files ?? [])].forEach(f => onUpload(f)) + if ('value' in event.currentTarget) event.currentTarget.value = '' + } + + return ( + + {uploadText != null ? ( + <> + {typeof uploadText === 'string' ? ( + + {uploadText} + + ) : ( + <>{uploadText} + )} + + ) : null} + + {uploadButtonText ?? t('upload')} + + + { + setIsHover(true) + }} + onMouseLeave={() => { + setIsHover(false) + }} + css={isFileOverDropZone ? DRAG_OVER_STYLES : undefined} + > + + {dragAndDropText} + + + + ) +} diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx b/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx index 4598eddc49e..d3014a5895b 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx +++ b/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx @@ -25,7 +25,6 @@ describe('MainContentContainer', () => { it('should render prompt guide and text', () => { render() - screen.getByText('OpentronsAI') screen.getByText('mock PromptGuide') screen.getByText('mock ChatFooter') }) diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx b/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx index b5b495a691e..cc2ad54bc39 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx +++ b/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx @@ -1,15 +1,12 @@ import { useRef, useEffect } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { useAtom } from 'jotai' import { - COLORS, DIRECTION_COLUMN, Flex, OVERFLOW_AUTO, SPACING, - LegacyStyledText, } from '@opentrons/components' import { PromptGuide } from '../../molecules/PromptGuide' import { ChatDisplay } from '../../molecules/ChatDisplay' @@ -17,7 +14,6 @@ import { ChatFooter } from '../../molecules/ChatFooter' import { chatDataAtom } from '../../resources/atoms' export function MainContentContainer(): JSX.Element { - const { t } = useTranslation('protocol_generator') const [chatData] = useAtom(chatDataAtom) const scrollRef = useRef(null) @@ -32,14 +28,11 @@ export function MainContentContainer(): JSX.Element { return ( - {/* Prompt Guide remain as a reference for users. */} - {t('opentronsai')} @@ -74,6 +65,5 @@ export function MainContentContainer(): JSX.Element { const ChatDataContainer = styled(Flex)` flex-direction: ${DIRECTION_COLUMN}; - grid-gap: ${SPACING.spacing40}; width: 100%; ` diff --git a/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx b/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx new file mode 100644 index 00000000000..04c3ad3b167 --- /dev/null +++ b/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import type { NavigateFunction } from 'react-router-dom' + +import { UpdateProtocol } from '../index' +import { i18n } from '../../../i18n' + +// global.Blob = BlobPolyfill as any +global.Blob = require('node:buffer').Blob + +const mockNavigate = vi.fn() +const mockUseTrackEvent = vi.fn() +const mockUseChatData = vi.fn() + +vi.mock('../../../resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +File.prototype.text = vi.fn().mockResolvedValue('test file content') + +vi.mock('../../../resources/chatDataAtom', () => ({ + chatDataAtom: () => mockUseChatData, +})) + +vi.mock('react-router-dom', async importOriginal => { + const reactRouterDom = await importOriginal() + return { + ...reactRouterDom, + useNavigate: () => mockNavigate, + } +}) + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('Update Protocol', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render', () => { + render() + expect(screen.getByText('Update an existing protocol')).toBeInTheDocument() + expect(screen.getByText('Choose file')).toBeInTheDocument() + expect(screen.getByText('Protocol file')).toBeInTheDocument() + expect(screen.getByText('Choose file')).toBeInTheDocument() + expect(screen.getByText('Type of update')).toBeInTheDocument() + expect(screen.getByText('Select an option')).toBeInTheDocument() + expect( + screen.getByText('Provide details of changes you want to make') + ).toBeInTheDocument() + }) + + it('should update the file value when the file is uploaded', async () => { + render() + + const blobParts: BlobPart[] = [ + 'x = 1\n', + 'x = 2\n', + 'x = 3\n', + 'x = 4\n', + 'print("x is 1.")\n', + ] + + const file = new File(blobParts, 'test-file.py', { type: 'text/python' }) + + fireEvent.drop(screen.getByTestId('file_drop_zone'), { + dataTransfer: { + files: [file], + }, + }) + + await waitFor(() => { + expect(screen.getByText('test-file.py')).toBeInTheDocument() + }) + }) + + it('should not proceed when you click the submit prompt when the progress percentage is not 1.0', () => { + render() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it.skip('should call navigate to the chat page when the submit prompt button is clicked when progress is 1.0', async () => { + render() + + // upload file + const blobParts: BlobPart[] = [ + 'x = 1\n', + 'x = 2\n', + 'x = 3\n', + 'x = 4\n', + 'print("x is 1.")\n', + ] + const file = new File(blobParts, 'test-file.py', { type: 'text/python' }) + fireEvent.drop(screen.getByTestId('file_drop_zone'), { + dataTransfer: { + files: [file], + }, + }) + + // input description + const describeInput = screen.getByRole('textbox') + fireEvent.change(describeInput, { target: { value: 'Test description' } }) + + expect(screen.getByDisplayValue('Test description')).toBeInTheDocument() + + // select update type + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const basicOtherOption = screen.getByText('Other') + fireEvent.click(basicOtherOption) + + const submitPromptButton = screen.getByText('Submit prompt') + await waitFor(() => { + expect(submitPromptButton).toBeEnabled() + submitPromptButton.click() + }) + expect(mockNavigate).toHaveBeenCalledWith('/chat') + }) +}) diff --git a/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx b/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx new file mode 100644 index 00000000000..f0f8f4c7e12 --- /dev/null +++ b/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx @@ -0,0 +1,287 @@ +import styled from 'styled-components' +import { + COLORS, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + InputField, + JUSTIFY_CENTER, + JUSTIFY_END, + LargeButton, + StyledText, + Link as LinkComponent, + DropdownMenu, +} from '@opentrons/components' +import type { DropdownOption } from '@opentrons/components' +import { UploadInput } from '../../molecules/UploadInput' +import { useEffect, useState } from 'react' +import type { ChangeEvent } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { FileUpload } from '../../molecules/FileUpload' +import { useNavigate } from 'react-router-dom' +import { chatPromptAtom, headerWithMeterAtom } from '../../resources/atoms' +import { CSSTransition } from 'react-transition-group' +import { useAtom } from 'jotai' + +const updateOptions: DropdownOption[] = [ + { + name: 'Adapt Python protocol from OT-2 to Flex', + value: 'adapt_python_protocol', + }, + { name: 'Change labware', value: 'change_labware' }, + { name: 'Change pipettes', value: 'change_pipettes' }, + { name: 'Other', value: 'other' }, +] + +const FadeWrapper = styled.div` + &.fade-enter { + opacity: 0; + } + &.fade-enter-active { + opacity: 1; + transition: opacity 1000ms; + } + &.fade-exit { + height: 100%; + opacity: 1; + } + &.fade-exit-active { + opacity: 0; + height: 0%; + transition: opacity 1000ms; + } +` + +const Container = styled(Flex)` + width: 100%; + flex-direction: ${DIRECTION_COLUMN}; + align-items: ${JUSTIFY_CENTER}; +` + +const Spacer = styled(Flex)` + height: 16px; +` + +const ContentBox = styled(Flex)` + background-color: white; + border-radius: 16px; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + padding: 32px 24px; + width: 60%; +` + +const HeadingText = styled(StyledText).attrs({ + desktopStyle: 'headingSmallBold', +})`` + +const BodyText = styled(StyledText).attrs({ + color: COLORS.grey60, + desktopStyle: 'bodyDefaultRegular', + paddingBottom: '8px', + paddingTop: '16px', +})`` + +const isValidProtocolFileName = (protocolFileName: string): boolean => { + return protocolFileName.endsWith('.py') +} + +export function UpdateProtocol(): JSX.Element { + const navigate = useNavigate() + const { t }: { t: (key: string) => string } = useTranslation( + 'protocol_generator' + ) + const [, setChatPrompt] = useAtom(chatPromptAtom) + const [headerState, setHeaderWithMeterAtom] = useAtom(headerWithMeterAtom) + const [updateType, setUpdateType] = useState(null) + const [detailsValue, setDetailsValue] = useState('') + const [fileValue, setFile] = useState(null) + const [pythonText, setPythonTextValue] = useState('') + const [errorText, setErrorText] = useState(null) + + useEffect(() => { + let progress = 0.0 + if (updateType !== null) { + progress += 0.33 + } + + if (detailsValue !== '') { + progress += 0.33 + } + + if (pythonText !== '' && fileValue !== null && errorText === null) { + progress += 0.34 + } + + setHeaderWithMeterAtom({ + displayHeaderWithMeter: true, + progress, + }) + }, [ + updateType, + detailsValue, + pythonText, + errorText, + fileValue, + setHeaderWithMeterAtom, + ]) + + const handleInputChange = (event: ChangeEvent): void => { + setDetailsValue(event.target.value) + } + + const handleFileUpload = async ( + file: File & { name: string } + ): Promise => { + if (isValidProtocolFileName(file.name)) { + const text = await file.text().catch(error => { + console.error('Error reading file:', error) + setErrorText(t('python_file_read_error')) + }) + + if (typeof text === 'string' && text !== '') { + setErrorText(null) + console.log('File read successfully:\n', text) + setPythonTextValue(text) + } else { + setErrorText(t('file_length_error')) + } + + setFile(file) + } else { + setErrorText(t('python_file_type_error')) + setFile(file) + } + } + + function processDataAndNavigateToChat(): void { + const introText = t('modify_intro') + const originalCodeText = + t('modify_python_code') + `\`\`\`python\n` + pythonText + `\n\`\`\`\n\n` + const updateTypeText = + t('modify_type_of_update') + updateType?.value + `\n\n` + const detailsText = t('modify_details_of_change') + detailsValue + '\n' + + const chatPrompt = `${introText}${originalCodeText}${updateTypeText}${detailsText}` + + console.log(chatPrompt) + + setChatPrompt(chatData => chatPrompt) + navigate('/chat') + } + + return ( + + + + {t('update_existing_protocol')} + {t('protocol_file')} + + + + {fileValue !== null ? ( + + + + ) : null} + + + + + + + + ), + }} + /> + + } + onUpload={async function (file: File) { + try { + await handleFileUpload(file) + } catch (error) { + // todo perhaps make this a toast? + console.error('Error uploading file:', error) + } + }} + /> + + + + + + { + const selectedOption = updateOptions.find(v => v.value === value) + if (selectedOption != null) { + setUpdateType(selectedOption) + } + }} + /> + + {t('provide_details_of_changes')} + + + + + + + ) +} diff --git a/opentrons-ai-client/src/pages/Chat/index.tsx b/opentrons-ai-client/src/pages/Chat/index.tsx new file mode 100644 index 00000000000..6d9492038b8 --- /dev/null +++ b/opentrons-ai-client/src/pages/Chat/index.tsx @@ -0,0 +1,38 @@ +import { useForm, FormProvider } from 'react-hook-form' +import { + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_CENTER, + POSITION_RELATIVE, +} from '@opentrons/components' + +import { MainContentContainer } from '../../organisms/MainContentContainer' + +export interface InputType { + userPrompt: string +} + +export function Chat(): JSX.Element | null { + const methods = useForm({ + defaultValues: { + userPrompt: '', + }, + }) + + return ( + + + + {/* */} + + + + + ) +} diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 229e9fbe739..b92f3e848f5 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -11,6 +11,9 @@ import type { /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) +/** ChatPromptAtom is for the prefilled userprompt when landing on the chat page */ +export const chatPromptAtom = atom('') + export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) diff --git a/protocol-designer/src/assets/localization/en/create_new_protocol.json b/protocol-designer/src/assets/localization/en/create_new_protocol.json index 1b9d1f72898..622464e6547 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -32,6 +32,8 @@ "show_default_tips": "Show default tips", "show_tips": "Show incompatible tips", "slots_limit_reached": "Slots limit reached", + "staging_area_has_labware": "This staging area slot has labware", + "staging_area_will_delete_labware": "The staging area slot that you are about to delete has labware placed on it. If you make these changes to your protocol starting deck, the labware will be deleted as well.", "stagingArea": "Staging area", "swap_pipettes": "Swap pipettes", "tell_us": "Tell us about your protocol", diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json index 1f3f4bc9e34..c5a1099fbc1 100644 --- a/protocol-designer/src/assets/localization/en/form.json +++ b/protocol-designer/src/assets/localization/en/form.json @@ -93,7 +93,7 @@ } }, "location": { - "dropTip": "drop tip location", + "dropTip": "Tip drop location", "label": "location", "pickUp": "pick up tip" }, @@ -278,8 +278,8 @@ "tipRack": "tip rack", "wellSelectionLabel": { "columns": "columns", - "columns_aspirate_wells": "Select source columns", - "columns_dispense_wells": "Select destination columns", + "columns_aspirate_wells": "Source columns", + "columns_dispense_wells": "Destination columns", "columns_mix_wells": "Select columns", "wells": "wells", "wells_aspirate_wells": "Select source wells", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index ea0339978ec..56becba78e7 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -2,6 +2,7 @@ "add_details": "Add step details", "advanced_settings": "Advanced pipetting settings", "air_gap_volume": "Air gap volume", + "aspirate_labware": "Source labware", "aspirate": "Aspirate", "aspirated": "Aspirated", "batch_edit_steps": "Batch edit steps", @@ -16,6 +17,7 @@ "delay_position": "Delay position from bottom", "delete_steps": "Delete steps", "delete": "Delete step", + "dispense_labware": "Destination labware", "dispense": "Dispense", "dispensed": "Dispensed", "disposal_volume": "Disposal volume", @@ -72,6 +74,7 @@ "untilTemperature": "Pausing until{{module}}reaches", "untilTime": "Pausing for" }, + "pipette": "Pipette", "protocol_steps": "Protocol steps", "protocol_timeline": "Protocol timeline", "rename": "Rename", @@ -124,10 +127,12 @@ } }, "time": "Time", + "tiprack": "Tiprack", "tip_position": "{{prefix}} tip position", "touch_tip_position": "Touch tip position from top", "valid_range": "Valid range between {{min}} - {{max}} {{unit}}", "view_details": "View details", + "volume_per_well": "Volume per well", "well_name": "Well {{wellName}}", "well_order_title": "{{prefix}} well order", "well_position": "Well position (x,y,z): " diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index dafebf2b62d..64a5cdffdcc 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -104,6 +104,7 @@ "ot2": "Opentrons OT-2", "overwrite_labware": "Overwrite labware", "overwrite": "Click Overwrite to replace the existing labware with the new labware.", + "part": "Part {{current}} / {{max}}", "pipette": "Pipette", "pd_version": "Protocol designer version", "primary_order": "Primary order", diff --git a/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx index 8c5426bdaae..195096f0c07 100644 --- a/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx @@ -55,7 +55,7 @@ describe('DropTipField', () => { }) it('renders the label and dropdown field with trash bin selected as default', () => { render(props) - screen.getByText('drop tip location') + screen.getByText('Tip drop location') screen.getByRole('combobox', { name: '' }) screen.getByRole('option', { name: 'Trash Bin' }) screen.getByRole('option', { name: 'mock tip' }) diff --git a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx index 6cd81c742c4..39da4a74ca9 100644 --- a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx +++ b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx @@ -1,5 +1,14 @@ import { useTranslation } from 'react-i18next' -import { DropdownMenu, Flex, SPACING } from '@opentrons/components' +import { useEffect } from 'react' +import { + COLORS, + DIRECTION_COLUMN, + DropdownMenu, + Flex, + ListItem, + SPACING, + StyledText, +} from '@opentrons/components' import type { Options } from '@opentrons/components' import type { FieldProps } from '../../pages/Designer/ProtocolSteps/StepForm/types' @@ -28,24 +37,49 @@ export function DropdownStepFormField( const { t } = useTranslation('tooltip') const availableOptionId = options.find(opt => opt.value === value) + useEffect(() => { + if (options.length === 1) { + updateValue(options[0].value) + } + }, []) + return ( - { - updateValue(value) - }} - /> + {options.length > 1 || options.length === 0 ? ( + { + updateValue(value) + }} + /> + ) : ( + + + {title} + + + + + {options[0].name} + + + + + )} ) } diff --git a/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx new file mode 100644 index 00000000000..9f6e2991e73 --- /dev/null +++ b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, beforeEach, vi, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { ConfirmDeleteStagingAreaModal } from '..' +import type { ComponentProps } from 'react' + +const render = ( + props: ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ConfirmDeleteStagingAreaModal', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + onClose: vi.fn(), + onConfirm: vi.fn(), + } + }) + it('renders the text and buttons work as expected', () => { + render(props) + screen.getByText('This staging area slot has labware') + screen.getByText( + 'The staging area slot that you are about to delete has labware placed on it. If you make these changes to your protocol starting deck, the labware will be deleted as well.' + ) + fireEvent.click(screen.getByText('Cancel')) + expect(props.onClose).toHaveBeenCalled() + fireEvent.click(screen.getByText('Continue')) + expect(props.onConfirm).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx new file mode 100644 index 00000000000..c2d0c81f0ea --- /dev/null +++ b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { + Flex, + JUSTIFY_END, + Modal, + PrimaryButton, + SecondaryButton, + SPACING, + StyledText, +} from '@opentrons/components' +import { getTopPortalEl } from '../../components/portals/TopPortal' +import { HandleEnter } from '../../atoms/HandleEnter' + +interface ConfirmDeleteStagingAreaModalProps { + onClose: () => void + onConfirm: () => void +} +export function ConfirmDeleteStagingAreaModal( + props: ConfirmDeleteStagingAreaModalProps +): JSX.Element { + const { onClose, onConfirm } = props + const { t, i18n } = useTranslation(['create_new_protocol', 'shared']) + + return createPortal( + + + { + onClose() + }} + > + {t('shared:cancel')} + + + {i18n.format(t('shared:continue'), 'capitalize')} + + + } + > + + {t('staging_area_will_delete_labware')} + + + , + getTopPortalEl() + ) +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index 3d6fee9b662..08e1745bccf 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -2,6 +2,7 @@ export * from './Alerts' export * from './AnnouncementModal' export * from './AssignLiquidsModal' export * from './BlockingHintModal' +export * from './ConfirmDeleteStagingAreaModal' export * from './DefineLiquidsModal' export * from './EditInstrumentsModal' export * from './EditNickNameModal' diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx index 00511d96c4d..2e45768cf4d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx @@ -67,7 +67,7 @@ const OT2_STANDARD_DECK_VIEW_LAYER_BLOCK_LIST: string[] = [ 'fixedTrash', ] export const lightFill = COLORS.grey35 -const darkFill = COLORS.grey60 +export const darkFill = COLORS.grey60 export function DeckSetupContainer(props: DeckSetupTabType): JSX.Element { const { tab } = props @@ -180,6 +180,7 @@ export function DeckSetupContainer(props: DeckSetupTabType): JSX.Element { height={zoomIn.slot != null ? '75vh' : '70vh'} flexDirection={DIRECTION_COLUMN} padding={SPACING.spacing40} + maxHeight="39.375rem" // this is to block deck view from enlarging > > + setHover: Dispatch> showGen1MultichannelCollisionWarnings: boolean stagingAreaCutoutIds: CutoutId[] selectedZoomInSlot?: DeckSlotId @@ -83,9 +85,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ) const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const { selectedSlot } = selectedSlotInfo - const [menuListId, setShowMenuListForId] = React.useState( - null - ) + const [menuListId, setShowMenuListForId] = useState(null) const dispatch = useDispatch() const { @@ -100,7 +100,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { deckDef, }) // initiate the slot's info - React.useEffect(() => { + useEffect(() => { dispatch( editSlotInfo({ createdNestedLabwareForSlot, @@ -132,6 +132,15 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ? getSlotsWithCollisions(deckDef, allModules) : [] + const adjacentLabware = + preSelectedFixture != null && selectedSlot.cutout != null + ? getAdjacentLabware( + preSelectedFixture, + selectedSlot.cutout, + activeDeckSetup.labware + ) + : null + return ( <> {/* all modules */} @@ -146,7 +155,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { const moduleDef = getModuleDef2(moduleOnDeck.model) const getModuleInnerProps = ( moduleState: ModuleTemporalProperties['moduleState'] - ): React.ComponentProps['innerProps'] => { + ): ComponentProps['innerProps'] => { if (moduleState.type === THERMOCYCLER_MODULE_TYPE) { let lidMotorState = 'unknown' if (tab === 'startingDeck' || moduleState.lidOpen) { @@ -186,7 +195,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { zDimension: labwareLoadedOnModule?.def.dimensions.zDimension ?? 0, } return moduleOnDeck.slot !== selectedSlot.slot ? ( - + ) : null} - + ) : null })} @@ -276,7 +285,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { }) .map(addressableArea => { return ( - + - + ) })} {/* all labware on deck NOT those in modules */} @@ -299,10 +308,10 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { if ( labware.slot === 'offDeck' || allModules.some(m => m.id === labware.slot) || - allLabware.some(lab => lab.id === labware.slot) + allLabware.some(lab => lab.id === labware.slot) || + labware.id === adjacentLabware?.id ) return null - const slotPosition = getPositionFromSlotId(labware.slot, deckDef) const slotBoundingBox = getAddressableAreaFromSlotId( labware.slot, @@ -313,7 +322,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { return null } return labware.slot !== selectedSlot.slot ? ( - + - + ) : null })} @@ -376,7 +385,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ? slotForOnTheDeck : allModules.find(module => module.id === slotForOnTheDeck)?.slot return ( - + - + ) })} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index 6c000ad0428..e73ab455dc7 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -20,6 +20,7 @@ import { MAGNETIC_MODULE_TYPE, MAGNETIC_MODULE_V1, MAGNETIC_MODULE_V2, + MODULE_MODELS, OT2_ROBOT_TYPE, } from '@opentrons/shared-data' @@ -46,6 +47,7 @@ import { selectors } from '../../../labware-ingred/selectors' import { useKitchen } from '../../../organisms/Kitchen/hooks' import { getDismissedHints } from '../../../tutorial/selectors' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' +import { ConfirmDeleteStagingAreaModal } from '../../../organisms' import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' import { getModuleModelsBySlot, getDeckErrors } from './utils' @@ -71,6 +73,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const { makeSnackbar } = useKitchen() const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const robotType = useSelector(getRobotType) + const [showDeleteLabwareModal, setShowDeleteLabwareModal] = useState< + ModuleModel | 'clear' | null + >(null) const isDismissedModuleHint = useSelector(getDismissedHints).includes( 'change_magnet_module_model' ) @@ -154,6 +159,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { createdModuleForSlot, createdLabwareForSlot, createFixtureForSlots, + matchingLabwareFor4thColumn, } = getSlotInformation({ deckSetup, slot }) let fixtures: Fixture[] = [] @@ -218,6 +224,10 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { if (createdNestedLabwareForSlot != null) { dispatch(deleteContainer({ labwareId: createdNestedLabwareForSlot.id })) } + // clear labware on staging area 4th column slot + if (matchingLabwareFor4thColumn != null) { + dispatch(deleteContainer({ labwareId: matchingLabwareFor4thColumn.id })) + } } handleResetToolbox() setSelectedHardware(null) @@ -278,6 +288,26 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { } return ( <> + {showDeleteLabwareModal != null ? ( + { + setShowDeleteLabwareModal(null) + }} + onConfirm={() => { + if (showDeleteLabwareModal === 'clear') { + handleClear() + handleResetToolbox() + } else if (MODULE_MODELS.includes(showDeleteLabwareModal)) { + setSelectedHardware(showDeleteLabwareModal) + dispatch(selectFixture({ fixture: null })) + dispatch(selectModule({ moduleModel: showDeleteLabwareModal })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch(selectNestedLabware({ nestedLabwareDefUri: null })) + } + setShowDeleteLabwareModal(null) + }} + /> + ) : null} {changeModuleWarning} } onCloseClick={() => { - handleClear() - handleResetToolbox() + if (matchingLabwareFor4thColumn != null) { + setShowDeleteLabwareModal('clear') + } else { + handleClear() + handleResetToolbox() + } }} onConfirmClick={() => { handleConfirm() @@ -407,6 +441,12 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { !isDismissedModuleHint ) { displayModuleWarning(true) + } else if ( + selectedFixture === 'stagingArea' || + (selectedFixture === 'wasteChuteAndStagingArea' && + matchingLabwareFor4thColumn != null) + ) { + setShowDeleteLabwareModal(model) } else { setSelectedHardware(model) dispatch(selectFixture({ fixture: null })) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx b/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx index 92803de701f..cf4d1129486 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react' +import { useSelector } from 'react-redux' import { COLORS, FlexTrash, @@ -7,7 +8,11 @@ import { WasteChuteFixture, WasteChuteStagingAreaFixture, } from '@opentrons/components' -import { lightFill } from './DeckSetupContainer' +import { getPositionFromSlotId } from '@opentrons/shared-data' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { LabwareOnDeck as LabwareOnDeckComponent } from '../../../components/DeckSetup/LabwareOnDeck' +import { lightFill, darkFill } from './DeckSetupContainer' +import { getAdjacentLabware } from './utils' import type { TrashCutoutId, StagingAreaLocation } from '@opentrons/components' import type { CutoutId, @@ -25,16 +30,34 @@ interface FixtureRenderProps { } export const FixtureRender = (props: FixtureRenderProps): JSX.Element => { const { fixture, cutout, deckDef, robotType } = props + const deckSetup = useSelector(getInitialDeckSetup) + const { labware } = deckSetup + const adjacentLabware = getAdjacentLabware(fixture, cutout, labware) + + const renderLabwareOnDeck = (): JSX.Element | null => { + if (!adjacentLabware) return null + const slotPosition = getPositionFromSlotId(adjacentLabware.slot, deckDef) + return ( + + ) + } switch (fixture) { case 'stagingArea': { return ( - + + + {renderLabwareOnDeck()} + ) } case 'trashBin': { @@ -67,12 +90,14 @@ export const FixtureRender = (props: FixtureRenderProps): JSX.Element => { } case 'wasteChuteAndStagingArea': { return ( - + + + {renderLabwareOnDeck()} + ) } } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx index 258f5fe07d6..71f07b973e3 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx @@ -1,5 +1,4 @@ import { useSelector } from 'react-redux' -import { FixtureRender } from './FixtureRender' import { LabwareRender, Module } from '@opentrons/components' import { getModuleDef2, @@ -10,6 +9,7 @@ import { getOnlyLatestDefs } from '../../../labware-defs' import { getCustomLabwareDefsByURI } from '../../../labware-defs/selectors' import { ModuleLabel } from './ModuleLabel' import { LabwareLabel } from '../LabwareLabel' +import { FixtureRender } from './FixtureRender' import type { CoordinateTuple, DeckDefinition, diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx index 974a6b96552..b29d3b90dfa 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useState } from 'react' import styled from 'styled-components' import { useDispatch, useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' @@ -20,15 +20,25 @@ import { import { getDeckSetupForActiveItem } from '../../../top-selectors/labware-locations' import { deleteModule } from '../../../step-forms/actions' -import { EditNickNameModal } from '../../../organisms' +import { + ConfirmDeleteStagingAreaModal, + EditNickNameModal, +} from '../../../organisms' import { deleteDeckFixture } from '../../../step-forms/actions/additionalItems' import { deleteContainer, duplicateLabware, openIngredientSelector, } from '../../../labware-ingred/actions' +import { getStagingAreaAddressableAreas } from '../../../utils' import { selectors as labwareIngredSelectors } from '../../../labware-ingred/selectors' -import type { CoordinateTuple, DeckSlotId } from '@opentrons/shared-data' +import type { MouseEvent, SetStateAction } from 'react' +import type { + CoordinateTuple, + CutoutId, + DeckSlotId, +} from '@opentrons/shared-data' +import type { LabwareOnDeck } from '../../../step-forms' import type { ThunkDispatch } from '../../../types' const ROBOT_BOTTOM_HALF_SLOTS = [ @@ -55,7 +65,7 @@ const TOP_SLOT_Y_POSITION_2_BUTTONS = 35 interface SlotOverflowMenuProps { // can be off-deck id or deck slot location: DeckSlotId | string - setShowMenuList: (value: React.SetStateAction) => void + setShowMenuList: (value: SetStateAction) => void addEquipment: (slotId: string) => void menuListSlotPosition?: CoordinateTuple } @@ -71,14 +81,14 @@ export function SlotOverflowMenu( const { t } = useTranslation('starting_deck_state') const navigate = useNavigate() const dispatch = useDispatch>() - const [showNickNameModal, setShowNickNameModal] = React.useState( + const [showDeleteLabwareModal, setShowDeleteLabwareModal] = useState( false ) + const [showNickNameModal, setShowNickNameModal] = useState(false) const overflowWrapperRef = useOnClickOutside({ onClickOutside: () => { - if (!showNickNameModal) { - setShowMenuList(false) - } + if (showNickNameModal || showDeleteLabwareModal) return + setShowMenuList(false) }, }) const deckSetup = useSelector(getDeckSetupForActiveItem) @@ -111,6 +121,20 @@ export function SlotOverflowMenu( const fixturesOnSlot = Object.values(additionalEquipmentOnDeck).filter( ae => ae.location?.split('cutout')[1] === location ) + const stagingAreaCutout = fixturesOnSlot.find( + fixture => fixture.name === 'stagingArea' + )?.location + + let matchingLabware: LabwareOnDeck | null = null + if (stagingAreaCutout != null) { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + stagingAreaCutout, + ] as CutoutId[]) + matchingLabware = + Object.values(deckSetupLabware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } const hasNoItems = moduleOnSlot == null && labwareOnSlot == null && fixturesOnSlot.length === 0 @@ -132,7 +156,12 @@ export function SlotOverflowMenu( if (nestedLabwareOnSlot != null) { dispatch(deleteContainer({ labwareId: nestedLabwareOnSlot.id })) } + // clear labware on staging area 4th column slot + if (matchingLabware != null) { + dispatch(deleteContainer({ labwareId: matchingLabware.id })) + } } + const showDuplicateBtn = (labwareOnSlot != null && !isLabwareAnAdapter && @@ -179,6 +208,19 @@ export function SlotOverflowMenu( }} /> ) : null} + {showDeleteLabwareModal ? ( + { + setShowDeleteLabwareModal(false) + setShowMenuList(false) + }} + onConfirm={() => { + handleClear() + setShowDeleteLabwareModal(false) + setShowMenuList(false) + }} + /> + ) : null} { + onClick={(e: MouseEvent) => { e.preventDefault() e.stopPropagation() }} @@ -206,7 +248,7 @@ export function SlotOverflowMenu( {showEditAndLiquidsBtns ? ( <> { + onClick={(e: MouseEvent) => { setShowNickNameModal(true) e.preventDefault() e.stopPropagation() @@ -254,9 +296,15 @@ export function SlotOverflowMenu( ) : null} { - handleClear() - setShowMenuList(false) + onClick={(e: MouseEvent) => { + if (matchingLabware != null) { + setShowDeleteLabwareModal(true) + e.preventDefault() + e.stopPropagation() + } else { + handleClear() + setShowMenuList(false) + } }} > diff --git a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts index 51a661eeed1..b231da91072 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts +++ b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts @@ -13,6 +13,7 @@ import { } from '@opentrons/shared-data' import { getOnlyLatestDefs } from '../../../labware-defs' +import { getStagingAreaAddressableAreas } from '../../../utils' import { FLEX_MODULE_MODELS, OT2_MODULE_MODELS, @@ -29,7 +30,12 @@ import type { ModuleModel, RobotType, } from '@opentrons/shared-data' -import type { InitialDeckSetup } from '../../../step-forms' +import type { + AllTemporalPropertiesForTimelineFrame, + InitialDeckSetup, + LabwareOnDeck, +} from '../../../step-forms' +import type { Fixture } from './constants' const OT2_TC_SLOTS = ['7', '8', '10', '11'] const FLEX_TC_SLOTS = ['A1', 'B1'] @@ -255,3 +261,22 @@ export function animateZoom(props: AnimateZoomProps): void { } requestAnimationFrame(animate) } + +export const getAdjacentLabware = ( + fixture: Fixture, + cutout: CutoutId, + labware: AllTemporalPropertiesForTimelineFrame['labware'] +): LabwareOnDeck | null => { + let adjacentLabware: LabwareOnDeck | null = null + if (fixture === 'stagingArea' || fixture === 'wasteChuteAndStagingArea') { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + cutout, + ]) + + adjacentLabware = + Object.values(labware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } + return adjacentLabware +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx index e853a76ddaf..db28d285a0d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx @@ -59,7 +59,8 @@ export function FlowRateField(props: FlowRateFieldProps): JSX.Element { let errorMessage: string | null = null if ( (!isPristine && passThruProps.value !== undefined && flowRateNum === 0) || - outOfBounds + outOfBounds || + (isPristine && flowRateNum === 0) ) { errorMessage = i18n.format( t('step_edit_form.field.flow_rate.error_out_of_bounds', { @@ -71,10 +72,10 @@ export function FlowRateField(props: FlowRateFieldProps): JSX.Element { } useEffect(() => { - if (isPristine && errorMessage != null) { + if (isPristine && passThruProps.value == null) { passThruProps.updateValue(defaultFlowRate) } - }, []) + }, [isPristine, passThruProps]) return ( ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PipetteField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PipetteField.tsx index 50eb6b59997..1f9053b0478 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PipetteField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PipetteField.tsx @@ -13,7 +13,7 @@ export const PipetteField = (props: FieldProps): JSX.Element => { {...props} options={pipetteOptions} value={value ? String(value) : null} - title={t('select_pipette')} + title={t('pipette')} /> ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TiprackField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TiprackField.tsx index 9f9cca5157e..df9d5ad4fd0 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TiprackField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TiprackField.tsx @@ -1,6 +1,14 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + ListItem, + SPACING, + StyledText, +} from '@opentrons/components' import { getPipetteEntities } from '../../../../../step-forms/selectors' import { getTiprackOptions } from '../../../../../ui/labware/selectors' import { DropdownStepFormField } from '../../../../../molecules' @@ -29,12 +37,34 @@ export function TiprackField(props: TiprackFieldProps): JSX.Element { }, [defaultTiprackUris, value, updateValue]) const hasMissingTiprack = defaultTiprackUris.length > tiprackOptions.length return ( - + <> + {tiprackOptions.length > 1 ? ( + + ) : ( + + + {t('tiprack')} + + + + + {tiprackOptions[0].name} + + + + + )} + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx index 2059a81b389..5f0658ef949 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx @@ -8,7 +8,7 @@ export function VolumeField(props: FieldProps): JSX.Element { return ( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx index 57125f7b8a1..f12f3e0dd02 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx @@ -1,7 +1,9 @@ import { createPortal } from 'react-dom' import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' + import { + ALIGN_CENTER, COLORS, DIRECTION_COLUMN, Flex, @@ -13,6 +15,7 @@ import { useHoverTooltip, } from '@opentrons/components' import { COLUMN } from '@opentrons/shared-data' + import { actions as stepsActions, getSelectedStepId, @@ -22,6 +25,7 @@ import { selectors as stepFormSelectors } from '../../../../../step-forms' import { SelectWellsModal } from '../../../../../organisms' import { getMainPagePortalEl } from '../../../../../components/portals/MainPageModalPortal' import { getNozzleType } from '../utils' + import type { FieldProps } from '../types' export type WellSelectionFieldProps = FieldProps & { @@ -94,8 +98,13 @@ export const WellSelectionField = ( const [targetProps, tooltipProps] = useHoverTooltip() return ( <> - - + + {i18n.format(label, 'capitalize')} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index fd76bb10ca0..cee9fdadb4f 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -10,11 +10,11 @@ import { Icon, POSITION_RELATIVE, PrimaryButton, - SPACING, SecondaryButton, + SPACING, StyledText, - TYPOGRAPHY, Toolbox, + TYPOGRAPHY, } from '@opentrons/components' import { stepIconsByType } from '../../../../form-types' import { FormAlerts } from '../../../../organisms' @@ -153,7 +153,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { 'capitalize' ), t, - }) as string + }) ) } else { setShowFormErrorsAndWarnings(true) @@ -175,7 +175,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { subHeader={ isMultiStepToolbox ? ( - {t('shared:step', { current: toolboxStep + 1, max: 2 })} + {t('shared:part', { current: toolboxStep + 1, max: 2 })} ) : null } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx index 72688f43146..a6956cd342d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx @@ -1,3 +1,50 @@ -export function CommentTools(): JSX.Element { - return
TODO: wire this up
+import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import type { ChangeEvent } from 'react' +import type { StepFormProps } from '../../types' + +export function CommentTools(props: StepFormProps): JSX.Element { + const { t, i18n } = useTranslation('form') + const { propsForFields } = props + + return ( + + + {i18n.format(t('step_edit_form.field.comment.label'), 'capitalize')} + + ) => { + propsForFields.message.updateValue(e.currentTarget.value) + }} + /> + + ) } + +// TODO: use TextArea component when we make it +const StyledTextArea = styled.textarea` + width: 100%; + height: 7rem; + box-sizing: border-box; + border: 1px solid ${COLORS.grey50}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; + font-size: ${TYPOGRAPHY.fontSizeH4}; + line-height: ${TYPOGRAPHY.lineHeight16}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + resize: none; +` diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx index 6a0315bb3bf..0abee471ef5 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react' import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { @@ -6,7 +5,6 @@ import { COLORS, DIRECTION_COLUMN, Flex, - ListItem, SPACING, StyledText, } from '@opentrons/components' @@ -30,40 +28,15 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element { const { t } = useTranslation(['application', 'form', 'protocol_steps']) const moduleLabwareOptions = useSelector(getHeaterShakerLabwareOptions) - useEffect(() => { - if (moduleLabwareOptions.length === 1) { - propsForFields.moduleId.updateValue(moduleLabwareOptions[0].value) - } - }, []) - const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) return ( - {moduleLabwareOptions.length > 1 ? ( - - ) : ( - - - {t('protocol_steps:module')} - - - - - {moduleLabwareOptions[0].name} - - - - - )} + { - if (moduleLabwareOptions.length === 1) { - propsForFields.moduleId.updateValue(moduleLabwareOptions[0].value) - } - }, []) - return ( - {moduleLabwareOptions.length > 1 ? ( - - ) : ( - - - {t('protocol_steps:module')} - - - - - {moduleLabwareOptions[0].name} - - - - - )} + {temperatureModuleIds != null ? temperatureModuleIds.map(id => @@ -73,7 +46,7 @@ export function TemperatureTools(props: StepFormProps): JSX.Element { ) => { + onChange={(e: ChangeEvent) => { propsForFields.setTemperature.updateValue( e.currentTarget.value ) @@ -96,7 +69,7 @@ export function TemperatureTools(props: StepFormProps): JSX.Element { ) => { + onChange={(e: ChangeEvent) => { propsForFields.setTemperature.updateValue( e.currentTarget.value ) diff --git a/protocol-designer/src/pages/Designer/__tests__/utils.test.ts b/protocol-designer/src/pages/Designer/__tests__/utils.test.ts index b1faa3f0d67..6be91d71b2a 100644 --- a/protocol-designer/src/pages/Designer/__tests__/utils.test.ts +++ b/protocol-designer/src/pages/Designer/__tests__/utils.test.ts @@ -117,6 +117,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockOt2DeckSetup, slot: '1' }) ).toEqual({ + matchingLabwareFor4thColumn: null, createdModuleForSlot: mockHS, createdLabwareForSlot: mockLabOnDeck1, createdNestedLabwareForSlot: mockLabOnDeck2, @@ -128,6 +129,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockOt2DeckSetup, slot: '2' }) ).toEqual({ + matchingLabwareFor4thColumn: null, createdLabwareForSlot: mockLabOnDeck3, createFixtureForSlots: [], slotPosition: null, @@ -142,12 +144,17 @@ describe('getSlotInformation', () => { } expect( getSlotInformation({ deckSetup: mockDeckSetup, slot: 'A1' }) - ).toEqual({ slotPosition: null, createFixtureForSlots: [] }) + ).toEqual({ + matchingLabwareFor4thColumn: null, + slotPosition: null, + createFixtureForSlots: [], + }) }) it('renders a trashbin for a Flex on slot A3', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'A3' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createFixtureForSlots: [mockTrash], preSelectedFixture: 'trashBin', @@ -157,6 +164,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D1' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createdModuleForSlot: mockHSFlex, createdLabwareForSlot: mockLabOnDeck1, @@ -168,6 +176,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D3' }) ).toEqual({ + matchingLabwareFor4thColumn: mockLabOnStagingArea, slotPosition: null, createFixtureForSlots: [mockWasteChute, mockStagingArea], preSelectedFixture: 'wasteChuteAndStagingArea', @@ -177,6 +186,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D4' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createdLabwareForSlot: mockLabOnStagingArea, createFixtureForSlots: [mockWasteChute, mockStagingArea], diff --git a/protocol-designer/src/pages/Designer/utils.ts b/protocol-designer/src/pages/Designer/utils.ts index c940e12c8d5..3110f9d519d 100644 --- a/protocol-designer/src/pages/Designer/utils.ts +++ b/protocol-designer/src/pages/Designer/utils.ts @@ -1,9 +1,14 @@ import { getPositionFromSlotId } from '@opentrons/shared-data' +import { getStagingAreaAddressableAreas } from '../../utils' import type { AdditionalEquipmentName, DeckSlot, } from '@opentrons/step-generation' -import type { CoordinateTuple, DeckDefinition } from '@opentrons/shared-data' +import type { + CoordinateTuple, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' import type { AllTemporalPropertiesForTimelineFrame, LabwareOnDeck, @@ -18,6 +23,7 @@ interface AdditionalEquipment { } interface SlotInformation { + matchingLabwareFor4thColumn: LabwareOnDeck | null slotPosition: CoordinateTuple | null createdModuleForSlot?: ModuleOnDeck createdLabwareForSlot?: LabwareOnDeck @@ -66,6 +72,24 @@ export const getSlotInformation = ( } ) + const fixturesOnSlot = Object.values(additionalEquipmentOnDeck).filter( + ae => ae.location?.split('cutout')[1] === slot + ) + const stagingAreaCutout = fixturesOnSlot.find( + fixture => fixture.name === 'stagingArea' + )?.location + + let matchingLabware: LabwareOnDeck | null = null + if (stagingAreaCutout != null) { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + stagingAreaCutout, + ] as CutoutId[]) + matchingLabware = + Object.values(deckSetupLabware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } + const preSelectedFixture = createFixtureForSlots != null && createFixtureForSlots.length === 2 ? ('wasteChuteAndStagingArea' as Fixture) @@ -78,5 +102,6 @@ export const getSlotInformation = ( createFixtureForSlots, preSelectedFixture, slotPosition: slotPosition, + matchingLabwareFor4thColumn: matchingLabware, } } diff --git a/robot-server/robot_server/health/router.py b/robot-server/robot_server/health/router.py index 86a78255cd7..a4ca84bd2c1 100644 --- a/robot-server/robot_server/health/router.py +++ b/robot-server/robot_server/health/router.py @@ -30,6 +30,7 @@ ] FLEX_LOG_PATHS = [ "/logs/serial.log", + "/logs/can_bus.log", "/logs/api.log", "/logs/server.log", "/logs/update_server.log", diff --git a/robot-server/robot_server/service/legacy/models/logs.py b/robot-server/robot_server/service/legacy/models/logs.py index 5a501fdd2ff..36ce947e2e3 100644 --- a/robot-server/robot_server/service/legacy/models/logs.py +++ b/robot-server/robot_server/service/legacy/models/logs.py @@ -6,6 +6,7 @@ class LogIdentifier(str, Enum): api = "api.log" serial = "serial.log" + can = "can_bus.log" server = "server.log" api_server = "combined_api_server.log" update_server = "update_server.log" diff --git a/robot-server/robot_server/service/legacy/routers/logs.py b/robot-server/robot_server/service/legacy/routers/logs.py index 69b92d5263c..b3f57f0d281 100644 --- a/robot-server/robot_server/service/legacy/routers/logs.py +++ b/robot-server/robot_server/service/legacy/routers/logs.py @@ -14,6 +14,7 @@ LogIdentifier.api_server: "opentrons-robot-server", LogIdentifier.update_server: "opentrons-update-server", LogIdentifier.touchscreen: "opentrons-robot-app", + LogIdentifier.can: "opentrons-api-serial-can", } diff --git a/robot-server/tests/integration/fixtures.py b/robot-server/tests/integration/fixtures.py index 910eb9256dd..58362d59652 100644 --- a/robot-server/tests/integration/fixtures.py +++ b/robot-server/tests/integration/fixtures.py @@ -50,6 +50,7 @@ def check_ot3_health_response(response: Response) -> None: "board_revision": "UNKNOWN", "logs": [ "/logs/serial.log", + "/logs/can_bus.log", "/logs/api.log", "/logs/server.log", "/logs/update_server.log", diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index b87d0dcf0fd..61521073fac 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -1621,8 +1621,16 @@ "properties": { "liquidId": { "title": "Liquidid", - "description": "Unique identifier of the liquid to load.", - "type": "string" + "description": "Unique identifier of the liquid to load. If this is the sentinel value EMPTY, all values of volumeByWell must be 0.", + "anyOf": [ + { + "type": "string" + }, + { + "enum": ["EMPTY"], + "type": "string" + } + ] }, "labwareId": { "title": "Labwareid", @@ -1631,7 +1639,7 @@ }, "volumeByWell": { "title": "Volumebywell", - "description": "Volume of liquid, in \u00b5L, loaded into each well by name, in this labware.", + "description": "Volume of liquid, in \u00b5L, loaded into each well by name, in this labware. If the liquid id is the sentinel value EMPTY, all volumes must be 0.", "type": "object", "additionalProperties": { "type": "number" diff --git a/step-generation/src/commandCreators/atomic/dispense.ts b/step-generation/src/commandCreators/atomic/dispense.ts index c06e0035f7b..c009ce34403 100644 --- a/step-generation/src/commandCreators/atomic/dispense.ts +++ b/step-generation/src/commandCreators/atomic/dispense.ts @@ -221,7 +221,7 @@ export const dispense: CommandCreator = ( }, flowRate, // pushOut will always be undefined in step-generation for now - // since there is no easy way to allow users to select a volume for it in PD + // since there is no easy way to allow users to for it in PD }, ...(isAirGap && { meta: { isAirGap } }), },