From 06488fe7de88d3a0688691ba8bb19ee0cc6a027b Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 5 Dec 2023 12:58:29 -0500 Subject: [PATCH] feat(shared-data,protocol-engine): Use the tallest fixture, not the tallest addressable area, for motion planning (#14082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes RSS-409. * Give every `cutoutFixture` a height. This new field represents the overall height of the physical thing mounted to the deck. This is different from the existing heights of the `addressableArea`s—those are logical "interaction points" that aren't necessarily tied to the physical geometry. * During a protocol run, make sure to move the pipette above the highest `cutoutFixture` height. Formerly, we were moving the pipette above the highest `addressableArea` height, which was dangerous because the physical thing could be taller than that. * Various small refactors. --- .../protocol_api/core/engine/protocol.py | 2 +- .../state/addressable_areas.py | 69 ++++++++++++++---- .../protocol_engine/state/geometry.py | 41 +++++++---- .../opentrons/protocol_engine/state/motion.py | 4 +- .../core/engine/test_protocol_core.py | 2 +- .../state/test_addressable_area_store.py | 2 + .../state/test_addressable_area_view.py | 70 ++++++++++++++++++- .../state/test_geometry_view.py | 33 ++++----- .../protocol_engine/state/test_motion_view.py | 6 +- .../deck/definitions/4/ot2_short_trash.json | 6 +- .../deck/definitions/4/ot2_standard.json | 6 +- .../deck/definitions/4/ot3_standard.json | 27 ++++--- shared-data/deck/schemas/4.json | 7 +- shared-data/js/types.ts | 1 + .../opentrons_shared_data/deck/dev_types.py | 1 + 15 files changed, 212 insertions(+), 65 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 9d75681c8a4..db7e4a7679b 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -612,7 +612,7 @@ def get_slot_center(self, slot_name: DeckSlotName) -> Point: def get_highest_z(self) -> float: """Get the highest Z point of all deck items.""" - return self._engine_client.state.geometry.get_all_labware_highest_z() + return self._engine_client.state.geometry.get_all_obstacle_highest_z() def get_labware_cores(self) -> List[LabwareCore]: """Get all loaded labware cores.""" diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index c7362bd83f5..023dbec10fb 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -1,6 +1,6 @@ """Basic addressable area data state and store.""" from dataclasses import dataclass -from typing import Dict, Set, Union, List +from typing import Dict, List, Optional, Set, Union from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 @@ -36,9 +36,30 @@ class AddressableAreaState: """State of all loaded addressable area resources.""" loaded_addressable_areas_by_name: Dict[str, AddressableArea] + """The addressable areas that have been loaded so far. + + When `use_simulated_deck_config` is `False`, these are the addressable areas that the + deck configuration provided. + + When `use_simulated_deck_config` is `True`, these are the addressable areas that have been + referenced by the protocol so far. + """ + potential_cutout_fixtures_by_cutout_id: Dict[str, Set[PotentialCutoutFixture]] + deck_definition: DeckDefinitionV4 + + deck_configuration: Optional[DeckConfigurationType] + """The host robot's full deck configuration. + + If `use_simulated_deck_config` is `True`, this is meaningless and this value is undefined. + In practice it will probably be `None` or `[]`. + + If `use_simulated_deck_config` is `False`, this will be non-`None`. + """ + use_simulated_deck_config: bool + """See `Config.use_simulated_deck_config`.""" def _get_conflicting_addressable_areas( @@ -115,6 +136,7 @@ def __init__( ) ) self._state = AddressableAreaState( + deck_configuration=deck_configuration, loaded_addressable_areas_by_name=loaded_addressable_areas_by_name, potential_cutout_fixtures_by_cutout_id={}, deck_definition=deck_definition, @@ -127,7 +149,11 @@ def handle_action(self, action: Action) -> None: self._handle_command(action.command) if isinstance(action, PlayAction): current_state = self._state - if action.deck_configuration is not None: + if ( + action.deck_configuration is not None + and not self._state.use_simulated_deck_config + ): + self._state.deck_configuration = action.deck_configuration self._state.loaded_addressable_areas_by_name = ( self._get_addressable_areas_from_deck_configuration( deck_config=action.deck_configuration, @@ -158,7 +184,7 @@ def _handle_command(self, command: Command) -> None: def _get_addressable_areas_from_deck_configuration( deck_config: DeckConfigurationType, deck_definition: DeckDefinitionV4 ) -> Dict[str, AddressableArea]: - """Load all provided addressable areas with a valid deck configuration.""" + """Return all addressable areas provided by the given deck configuration.""" # TODO uncomment once execute is hooked up with this properly # assert ( # len(deck_config) == 12 @@ -196,11 +222,10 @@ def _check_location_is_addressable_area( addressable_area_name = location if addressable_area_name not in self._state.loaded_addressable_areas_by_name: - # TODO Uncomment this out once robot server side stuff is hooked up - # if not self._state.use_simulated_deck_config: - # raise AreaNotInDeckConfigurationError( - # f"{addressable_area_name} not provided by deck configuration." - # ) + # TODO Validate that during an actual run, the deck configuration provides the requested + # addressable area. If it does not, MoveToAddressableArea.execute() needs to raise; + # this store class cannot raise because Protocol Engine stores are not allowed to. + cutout_id = self._validate_addressable_area_for_simulation( addressable_area_name ) @@ -244,6 +269,9 @@ def _validate_addressable_area_for_simulation( set(self.state.loaded_addressable_areas_by_name), self._state.deck_definition, ) + # FIXME(mm, 2023-12-01): This needs to be raised from within + # MoveToAddressableAreaImplementation.execute(). Protocol Engine stores are not + # allowed to raise. raise IncompatibleAddressableAreaError( f"Cannot load {addressable_area_name}, not compatible with one or more of" f" the following areas: {loaded_areas_on_cutout}" @@ -282,6 +310,21 @@ def get_all(self) -> List[str]: """Get a list of all loaded addressable area names.""" return list(self._state.loaded_addressable_areas_by_name) + def get_all_cutout_fixtures(self) -> Optional[List[str]]: + """Get the names of all fixtures present in the host robot's deck configuration. + + If `use_simulated_deck_config` is `True` (see `Config`), we don't have a + meaningful concrete layout of fixtures, so this will return `None`. + """ + if self._state.use_simulated_deck_config: + return None + else: + assert self._state.deck_configuration is not None + return [ + cutout_fixture_id + for _, cutout_fixture_id in self._state.deck_configuration + ] + def _get_loaded_addressable_area( self, addressable_area_name: str ) -> AddressableArea: @@ -377,10 +420,12 @@ def get_addressable_area_center(self, addressable_area_name: str) -> Point: z=position.z, ) - def get_addressable_area_height(self, addressable_area_name: str) -> float: - """Get the z height of an addressable area.""" - addressable_area = self.get_addressable_area(addressable_area_name) - return addressable_area.bounding_box.z + def get_fixture_height(self, cutout_fixture_name: str) -> float: + """Get the z height of a cutout fixture.""" + cutout_fixture = deck_configuration_provider.get_cutout_fixture( + cutout_fixture_name, self._state.deck_definition + ) + return cutout_fixture["height"] def get_slot_definition(self, slot: DeckSlotName) -> SlotDefV3: """Get the definition of a slot in the deck.""" diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index a9453cadcff..99703fecd3a 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -89,9 +89,8 @@ def get_labware_highest_z(self, labware_id: str) -> float: return self._get_highest_z_from_labware_data(labware_data) - # TODO(mc, 2022-06-24): rename this method - def get_all_labware_highest_z(self) -> float: - """Get the highest Z-point across all labware.""" + def get_all_obstacle_highest_z(self) -> float: + """Get the highest Z-point across all obstacles that the instruments need to fly over.""" highest_labware_z = max( ( self._get_highest_z_from_labware_data(lw_data) @@ -109,15 +108,33 @@ def get_all_labware_highest_z(self) -> float: default=0.0, ) - highest_addressable_area_z = max( - ( - self._addressable_areas.get_addressable_area_height(area_name) - for area_name in self._addressable_areas.get_all() - ), - default=0.0, - ) + cutout_fixture_names = self._addressable_areas.get_all_cutout_fixtures() + if cutout_fixture_names is None: + # We're using a simulated deck config (see `Config.use_simulated_deck_config`). + # We only know the addressable areas referenced by the protocol, not the fixtures + # providing them. And there is more than one possible configuration of fixtures + # to provide them. So, we can't know what the highest fixture is. Default to 0. + # + # Defaulting to 0 may not be the right thing to do here. + # For example, suppose a protocol references an addressable area that implies a tall + # fixture must be on the deck, and then it uses long tips that wouldn't be able to + # clear the top of that fixture. We should perhaps raise an analysis error for that, + # but defaulting to 0 here means we won't. + highest_fixture_z = 0.0 + else: + highest_fixture_z = max( + ( + self._addressable_areas.get_fixture_height(cutout_fixture_name) + for cutout_fixture_name in cutout_fixture_names + ), + default=0.0, + ) - return max(highest_labware_z, highest_module_z, highest_addressable_area_z) + return max( + highest_labware_z, + highest_module_z, + highest_fixture_z, + ) def get_min_travel_z( self, @@ -134,7 +151,7 @@ def get_min_travel_z( ): min_travel_z = self.get_labware_highest_z(labware_id) else: - min_travel_z = self.get_all_labware_highest_z() + min_travel_z = self.get_all_obstacle_highest_z() if minimum_z_height: min_travel_z = max(min_travel_z, minimum_z_height) return min_travel_z diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index 310ead69c6f..4613b69e5b2 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -160,7 +160,7 @@ def get_movement_waypoints_to_addressable_area( # TODO(jbl 11-28-2023) This may need to change for partial tip configurations on a 96 destination_cp = CriticalPoint.XY_CENTER - all_labware_highest_z = self._geometry.get_all_labware_highest_z() + all_labware_highest_z = self._geometry.get_all_obstacle_highest_z() if minimum_z_height is None: minimum_z_height = float("-inf") min_travel_z = max(all_labware_highest_z, minimum_z_height) @@ -215,7 +215,7 @@ def get_movement_waypoints_to_coords( Ignored if `direct` is True. If lower than the default height, the default is used; this can only increase the height, not decrease it. """ - all_labware_highest_z = self._geometry.get_all_labware_highest_z() + all_labware_highest_z = self._geometry.get_all_obstacle_highest_z() if additional_min_travel_z is None: additional_min_travel_z = float("-inf") min_travel_z = max(all_labware_highest_z, additional_min_travel_z) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py index 6cf46c88839..643cf11a20a 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py @@ -1552,7 +1552,7 @@ def test_get_highest_z( ) -> None: """It should return a slot center from engine state.""" decoy.when( - mock_engine_client.state.geometry.get_all_labware_highest_z() + mock_engine_client.state.geometry.get_all_obstacle_highest_z() ).then_return(9001) result = subject.get_highest_z() diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py index f568b3eebac..cae97004ccf 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py @@ -91,6 +91,7 @@ def test_initial_state_simulated( loaded_addressable_areas_by_name={}, potential_cutout_fixtures_by_cutout_id={}, deck_definition=ot3_standard_deck_def, + deck_configuration=[], use_simulated_deck_config=True, ) @@ -103,6 +104,7 @@ def test_initial_state( assert subject.state.potential_cutout_fixtures_by_cutout_id == {} assert not subject.state.use_simulated_deck_config assert subject.state.deck_definition == ot3_standard_deck_def + assert subject.state.deck_configuration == _make_deck_config() # Loading 9 regular slots, 1 trash, 2 Staging Area slots and 3 waste chute types assert len(subject.state.loaded_addressable_areas_by_name) == 15 diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py index 2a7c819a882..93efce11c41 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py @@ -21,6 +21,7 @@ from opentrons.protocol_engine.types import ( AddressableArea, AreaType, + DeckConfigurationType, PotentialCutoutFixture, Dimensions, DeckPoint, @@ -29,8 +30,10 @@ @pytest.fixture(autouse=True) -def patch_mock_move_types(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: - """Mock out move_types.py functions.""" +def patch_mock_deck_configuration_provider( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Mock out deck_configuration_provider.py functions.""" for name, func in inspect.getmembers( deck_configuration_provider, inspect.isfunction ): @@ -43,6 +46,7 @@ def get_addressable_area_view( Dict[str, Set[PotentialCutoutFixture]] ] = None, deck_definition: Optional[DeckDefinitionV4] = None, + deck_configuration: Optional[DeckConfigurationType] = None, use_simulated_deck_config: bool = False, ) -> AddressableAreaView: """Get a labware view test subject.""" @@ -51,12 +55,37 @@ def get_addressable_area_view( potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id or {}, deck_definition=deck_definition or cast(DeckDefinitionV4, {"otId": "fake"}), + deck_configuration=deck_configuration or [], use_simulated_deck_config=use_simulated_deck_config, ) return AddressableAreaView(state=state) +def test_get_all_cutout_fixtures_simulated_deck_config() -> None: + """It should return no cutout fixtures when the deck config is simulated.""" + subject = get_addressable_area_view( + deck_configuration=None, + use_simulated_deck_config=True, + ) + assert subject.get_all_cutout_fixtures() is None + + +def test_get_all_cutout_fixtures_non_simulated_deck_config() -> None: + """It should return the cutout fixtures from the deck config, if it's not simulated.""" + subject = get_addressable_area_view( + deck_configuration=[ + ("cutout-id-1", "cutout-fixture-id-1"), + ("cutout-id-2", "cutout-fixture-id-2"), + ], + use_simulated_deck_config=False, + ) + assert subject.get_all_cutout_fixtures() == [ + "cutout-fixture-id-1", + "cutout-fixture-id-2", + ] + + def test_get_loaded_addressable_area() -> None: """It should get the loaded addressable area.""" addressable_area = AddressableArea( @@ -251,6 +280,43 @@ def test_get_addressable_area_center() -> None: assert result == Point(6, 12, 3) +def test_get_fixture_height(decoy: Decoy) -> None: + """It should return the height of the requested fixture.""" + subject = get_addressable_area_view() + decoy.when( + deck_configuration_provider.get_cutout_fixture( + "someShortCutoutFixture", subject.state.deck_definition + ) + ).then_return( + { + "height": 10, + # These values don't matter: + "id": "id", + "mayMountTo": [], + "displayName": "", + "providesAddressableAreas": {}, + } + ) + + decoy.when( + deck_configuration_provider.get_cutout_fixture( + "someTallCutoutFixture", subject.state.deck_definition + ) + ).then_return( + { + "height": 9000.1, + # These values don't matter: + "id": "id", + "mayMountTo": [], + "displayName": "", + "providesAddressableAreas": {}, + } + ) + + assert subject.get_fixture_height("someShortCutoutFixture") == 10 + assert subject.get_fixture_height("someTallCutoutFixture") == 9000.1 + + def test_get_slot_definition() -> None: """It should return a deck slot's definition.""" subject = get_addressable_area_view( diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 81aa77cc2a8..a92b9aa9f05 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -437,7 +437,7 @@ def test_get_module_labware_highest_z( assert highest_z == (well_plate_def.dimensions.zDimension + 3 + 3 + 6 + 0.5) -def test_get_all_labware_highest_z_no_equipment( +def test_get_all_obstacle_highest_z_no_equipment( decoy: Decoy, labware_view: LabwareView, module_view: ModuleView, @@ -449,12 +449,12 @@ def test_get_all_labware_highest_z_no_equipment( decoy.when(labware_view.get_all()).then_return([]) decoy.when(addressable_area_view.get_all()).then_return([]) - result = subject.get_all_labware_highest_z() + result = subject.get_all_obstacle_highest_z() assert result == 0 -def test_get_all_labware_highest_z( +def test_get_all_obstacle_highest_z( decoy: Decoy, well_plate_def: LabwareDefinition, reservoir_def: LabwareDefinition, @@ -524,13 +524,13 @@ def test_get_all_labware_highest_z( plate_z = subject.get_labware_highest_z("plate-id") reservoir_z = subject.get_labware_highest_z("reservoir-id") - all_z = subject.get_all_labware_highest_z() + all_z = subject.get_all_obstacle_highest_z() # Should exclude the off-deck plate. assert all_z == max(plate_z, reservoir_z) -def test_get_all_labware_highest_z_with_staging_area( +def test_get_all_obstacle_highest_z_with_staging_area( decoy: Decoy, well_plate_def: LabwareDefinition, falcon_tuberack_def: LabwareDefinition, @@ -585,13 +585,12 @@ def test_get_all_labware_highest_z_with_staging_area( ) staging_z = subject.get_labware_highest_z("staging-id") - all_z = subject.get_all_labware_highest_z() + all_z = subject.get_all_obstacle_highest_z() - # Should exclude the off-deck plate. assert all_z == staging_z -def test_get_all_labware_highest_z_with_modules( +def test_get_all_obstacle_highest_z_with_modules( decoy: Decoy, labware_view: LabwareView, module_view: ModuleView, @@ -609,31 +608,29 @@ def test_get_all_labware_highest_z_with_modules( decoy.when(module_view.get_overall_height("module-id-1")).then_return(42.0) decoy.when(module_view.get_overall_height("module-id-2")).then_return(1337.0) - result = subject.get_all_labware_highest_z() + result = subject.get_all_obstacle_highest_z() assert result == 1337.0 -def test_get_all_labware_highest_z_with_addressable_area( +def test_get_all_obstacle_highest_z_with_fixtures( decoy: Decoy, labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, subject: GeometryView, ) -> None: - """It should get the highest Z including addressable areas.""" + """It should get the highest Z including fixtures.""" decoy.when(labware_view.get_all()).then_return([]) decoy.when(module_view.get_all()).then_return([]) - decoy.when(addressable_area_view.get_all()).then_return(["abc", "xyz"]) - decoy.when(addressable_area_view.get_addressable_area_height("abc")).then_return( - 42.0 - ) - decoy.when(addressable_area_view.get_addressable_area_height("xyz")).then_return( - 1337.0 + decoy.when(addressable_area_view.get_all_cutout_fixtures()).then_return( + ["abc", "xyz"] ) + decoy.when(addressable_area_view.get_fixture_height("abc")).then_return(42.0) + decoy.when(addressable_area_view.get_fixture_height("xyz")).then_return(1337.0) - result = subject.get_all_labware_highest_z() + result = subject.get_all_obstacle_highest_z() assert result == 1337.0 diff --git a/api/tests/opentrons/protocol_engine/state/test_motion_view.py b/api/tests/opentrons/protocol_engine/state/test_motion_view.py index f1da69aa1e4..9e9920a7d3f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_motion_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_motion_view.py @@ -352,7 +352,7 @@ def test_get_movement_waypoints_to_addressable_area( decoy.when( addressable_area_view.get_addressable_area_move_to_location("area-name") ).then_return(Point(x=3, y=3, z=3)) - decoy.when(geometry_view.get_all_labware_highest_z()).then_return(42) + decoy.when(geometry_view.get_all_obstacle_highest_z()).then_return(42) decoy.when( addressable_area_view.get_addressable_area_base_slot("area-name") @@ -430,7 +430,7 @@ def test_get_movement_waypoints_to_coords( dest = Point(4, 5, 6) max_travel_z = 789 - decoy.when(geometry_view.get_all_labware_highest_z()).then_return( + decoy.when(geometry_view.get_all_obstacle_highest_z()).then_return( all_labware_highest_z ) @@ -472,7 +472,7 @@ def test_get_movement_waypoints_to_coords_raises( subject: MotionView, ) -> None: """It should raise FailedToPlanMoveError if motion_planning.get_waypoints raises.""" - decoy.when(geometry_view.get_all_labware_highest_z()).then_return(123) + decoy.when(geometry_view.get_all_obstacle_highest_z()).then_return(123) decoy.when( # TODO(mm, 2022-06-22): We should use decoy.matchers.Anything() for all # arguments. For some reason, Decoy does not match the call unless we diff --git a/shared-data/deck/definitions/4/ot2_short_trash.json b/shared-data/deck/definitions/4/ot2_short_trash.json index 7dfb7cfc1aa..6d566cb3801 100644 --- a/shared-data/deck/definitions/4/ot2_short_trash.json +++ b/shared-data/deck/definitions/4/ot2_short_trash.json @@ -389,7 +389,8 @@ "cutout10": ["10"], "cutout11": ["11"], "cutout12": ["12"] - } + }, + "height": 0 }, { "id": "fixedTrashSlot", @@ -397,7 +398,8 @@ "displayName": "Fixed Trash", "providesAddressableAreas": { "cutout12": ["shortFixedTrash"] - } + }, + "height": 58 } ] } diff --git a/shared-data/deck/definitions/4/ot2_standard.json b/shared-data/deck/definitions/4/ot2_standard.json index eb6d446f69a..206a72eaa0b 100644 --- a/shared-data/deck/definitions/4/ot2_standard.json +++ b/shared-data/deck/definitions/4/ot2_standard.json @@ -389,7 +389,8 @@ "cutout10": ["10"], "cutout11": ["11"], "cutout12": ["12"] - } + }, + "height": 0 }, { "id": "fixedTrashSlot", @@ -397,7 +398,8 @@ "displayName": "Fixed Trash", "providesAddressableAreas": { "cutout12": ["fixedTrash"] - } + }, + "height": 82 } ] } diff --git a/shared-data/deck/definitions/4/ot3_standard.json b/shared-data/deck/definitions/4/ot3_standard.json index 1c4dc0fca05..8f901698622 100644 --- a/shared-data/deck/definitions/4/ot3_standard.json +++ b/shared-data/deck/definitions/4/ot3_standard.json @@ -479,7 +479,8 @@ "cutoutC1": ["C1"], "cutoutB1": ["B1"], "cutoutA1": ["A1"] - } + }, + "height": 0 }, { "id": "singleCenterSlot", @@ -490,7 +491,8 @@ "cutoutC2": ["C2"], "cutoutB2": ["B2"], "cutoutA2": ["A2"] - } + }, + "height": 0 }, { "id": "singleRightSlot", @@ -501,7 +503,8 @@ "cutoutC3": ["C3"], "cutoutB3": ["B3"], "cutoutA3": ["A3"] - } + }, + "height": 0 }, { "id": "stagingAreaRightSlot", @@ -512,7 +515,8 @@ "cutoutC3": ["C3", "C4"], "cutoutB3": ["B3", "B4"], "cutoutA3": ["A3", "A4"] - } + }, + "height": 0 }, { "id": "trashBinAdapter", @@ -536,7 +540,8 @@ "cutoutC3": ["movableTrashC3"], "cutoutB3": ["movableTrashB3"], "cutoutA3": ["movableTrashA3"] - } + }, + "height": 40 }, { "id": "wasteChuteRightAdapterCovered", @@ -544,7 +549,8 @@ "displayName": "Waste Chute Adapter for 1 or 8 Channel Pipettes", "providesAddressableAreas": { "cutoutD3": ["1and8ChannelWasteChute"] - } + }, + "height": 124.5 }, { "id": "wasteChuteRightAdapterNoCover", @@ -556,7 +562,8 @@ "96ChannelWasteChute", "gripperWasteChute" ] - } + }, + "height": 124.5 }, { "id": "stagingAreaSlotWithWasteChuteRightAdapterCovered", @@ -564,7 +571,8 @@ "displayName": "Staging Slot With Waste Chute Adapter for 96 Channel Pipette or Gripper", "providesAddressableAreas": { "cutoutD3": ["1and8ChannelWasteChute", "D4"] - } + }, + "height": 124.5 }, { "id": "stagingAreaSlotWithWasteChuteRightAdapterNoCover", @@ -577,7 +585,8 @@ "gripperWasteChute", "D4" ] - } + }, + "height": 124.5 } ], "gripperOffsets": { diff --git a/shared-data/deck/schemas/4.json b/shared-data/deck/schemas/4.json index 368d2d50d31..b6f23db3df0 100644 --- a/shared-data/deck/schemas/4.json +++ b/shared-data/deck/schemas/4.json @@ -265,7 +265,8 @@ "id", "mayMountTo", "displayName", - "providesAddressableAreas" + "providesAddressableAreas", + "height" ], "properties": { "id": { @@ -292,6 +293,10 @@ "type": "string" } } + }, + "height": { + "description": "The vertical distance (mm) from the cutout fixture's origin to its tallest physical feature that an instrument could collide with.", + "type": "number" } } } diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 3570d42ddbf..577ab42fe6f 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -266,6 +266,7 @@ export interface CutoutFixture { mayMountTo: CutoutId[] displayName: string providesAddressableAreas: Record + height: number } type AreaType = 'slot' | 'movableTrash' | 'wasteChute' | 'fixedTrash' diff --git a/shared-data/python/opentrons_shared_data/deck/dev_types.py b/shared-data/python/opentrons_shared_data/deck/dev_types.py index 3ccc5357ec2..1698d44ad2f 100644 --- a/shared-data/python/opentrons_shared_data/deck/dev_types.py +++ b/shared-data/python/opentrons_shared_data/deck/dev_types.py @@ -116,6 +116,7 @@ class CutoutFixture(TypedDict): mayMountTo: List[str] displayName: str providesAddressableAreas: Dict[str, List[str]] + height: float Fixture = Union[