Skip to content

Commit 06488fe

Browse files
feat(shared-data,protocol-engine): Use the tallest fixture, not the tallest addressable area, for motion planning (#14082)
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.
1 parent 521b190 commit 06488fe

File tree

15 files changed

+212
-65
lines changed

15 files changed

+212
-65
lines changed

api/src/opentrons/protocol_api/core/engine/protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ def get_slot_center(self, slot_name: DeckSlotName) -> Point:
612612

613613
def get_highest_z(self) -> float:
614614
"""Get the highest Z point of all deck items."""
615-
return self._engine_client.state.geometry.get_all_labware_highest_z()
615+
return self._engine_client.state.geometry.get_all_obstacle_highest_z()
616616

617617
def get_labware_cores(self) -> List[LabwareCore]:
618618
"""Get all loaded labware cores."""

api/src/opentrons/protocol_engine/state/addressable_areas.py

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Basic addressable area data state and store."""
22
from dataclasses import dataclass
3-
from typing import Dict, Set, Union, List
3+
from typing import Dict, List, Optional, Set, Union
44

55
from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3
66

@@ -36,9 +36,30 @@ class AddressableAreaState:
3636
"""State of all loaded addressable area resources."""
3737

3838
loaded_addressable_areas_by_name: Dict[str, AddressableArea]
39+
"""The addressable areas that have been loaded so far.
40+
41+
When `use_simulated_deck_config` is `False`, these are the addressable areas that the
42+
deck configuration provided.
43+
44+
When `use_simulated_deck_config` is `True`, these are the addressable areas that have been
45+
referenced by the protocol so far.
46+
"""
47+
3948
potential_cutout_fixtures_by_cutout_id: Dict[str, Set[PotentialCutoutFixture]]
49+
4050
deck_definition: DeckDefinitionV4
51+
52+
deck_configuration: Optional[DeckConfigurationType]
53+
"""The host robot's full deck configuration.
54+
55+
If `use_simulated_deck_config` is `True`, this is meaningless and this value is undefined.
56+
In practice it will probably be `None` or `[]`.
57+
58+
If `use_simulated_deck_config` is `False`, this will be non-`None`.
59+
"""
60+
4161
use_simulated_deck_config: bool
62+
"""See `Config.use_simulated_deck_config`."""
4263

4364

4465
def _get_conflicting_addressable_areas(
@@ -115,6 +136,7 @@ def __init__(
115136
)
116137
)
117138
self._state = AddressableAreaState(
139+
deck_configuration=deck_configuration,
118140
loaded_addressable_areas_by_name=loaded_addressable_areas_by_name,
119141
potential_cutout_fixtures_by_cutout_id={},
120142
deck_definition=deck_definition,
@@ -127,7 +149,11 @@ def handle_action(self, action: Action) -> None:
127149
self._handle_command(action.command)
128150
if isinstance(action, PlayAction):
129151
current_state = self._state
130-
if action.deck_configuration is not None:
152+
if (
153+
action.deck_configuration is not None
154+
and not self._state.use_simulated_deck_config
155+
):
156+
self._state.deck_configuration = action.deck_configuration
131157
self._state.loaded_addressable_areas_by_name = (
132158
self._get_addressable_areas_from_deck_configuration(
133159
deck_config=action.deck_configuration,
@@ -158,7 +184,7 @@ def _handle_command(self, command: Command) -> None:
158184
def _get_addressable_areas_from_deck_configuration(
159185
deck_config: DeckConfigurationType, deck_definition: DeckDefinitionV4
160186
) -> Dict[str, AddressableArea]:
161-
"""Load all provided addressable areas with a valid deck configuration."""
187+
"""Return all addressable areas provided by the given deck configuration."""
162188
# TODO uncomment once execute is hooked up with this properly
163189
# assert (
164190
# len(deck_config) == 12
@@ -196,11 +222,10 @@ def _check_location_is_addressable_area(
196222
addressable_area_name = location
197223

198224
if addressable_area_name not in self._state.loaded_addressable_areas_by_name:
199-
# TODO Uncomment this out once robot server side stuff is hooked up
200-
# if not self._state.use_simulated_deck_config:
201-
# raise AreaNotInDeckConfigurationError(
202-
# f"{addressable_area_name} not provided by deck configuration."
203-
# )
225+
# TODO Validate that during an actual run, the deck configuration provides the requested
226+
# addressable area. If it does not, MoveToAddressableArea.execute() needs to raise;
227+
# this store class cannot raise because Protocol Engine stores are not allowed to.
228+
204229
cutout_id = self._validate_addressable_area_for_simulation(
205230
addressable_area_name
206231
)
@@ -244,6 +269,9 @@ def _validate_addressable_area_for_simulation(
244269
set(self.state.loaded_addressable_areas_by_name),
245270
self._state.deck_definition,
246271
)
272+
# FIXME(mm, 2023-12-01): This needs to be raised from within
273+
# MoveToAddressableAreaImplementation.execute(). Protocol Engine stores are not
274+
# allowed to raise.
247275
raise IncompatibleAddressableAreaError(
248276
f"Cannot load {addressable_area_name}, not compatible with one or more of"
249277
f" the following areas: {loaded_areas_on_cutout}"
@@ -282,6 +310,21 @@ def get_all(self) -> List[str]:
282310
"""Get a list of all loaded addressable area names."""
283311
return list(self._state.loaded_addressable_areas_by_name)
284312

313+
def get_all_cutout_fixtures(self) -> Optional[List[str]]:
314+
"""Get the names of all fixtures present in the host robot's deck configuration.
315+
316+
If `use_simulated_deck_config` is `True` (see `Config`), we don't have a
317+
meaningful concrete layout of fixtures, so this will return `None`.
318+
"""
319+
if self._state.use_simulated_deck_config:
320+
return None
321+
else:
322+
assert self._state.deck_configuration is not None
323+
return [
324+
cutout_fixture_id
325+
for _, cutout_fixture_id in self._state.deck_configuration
326+
]
327+
285328
def _get_loaded_addressable_area(
286329
self, addressable_area_name: str
287330
) -> AddressableArea:
@@ -377,10 +420,12 @@ def get_addressable_area_center(self, addressable_area_name: str) -> Point:
377420
z=position.z,
378421
)
379422

380-
def get_addressable_area_height(self, addressable_area_name: str) -> float:
381-
"""Get the z height of an addressable area."""
382-
addressable_area = self.get_addressable_area(addressable_area_name)
383-
return addressable_area.bounding_box.z
423+
def get_fixture_height(self, cutout_fixture_name: str) -> float:
424+
"""Get the z height of a cutout fixture."""
425+
cutout_fixture = deck_configuration_provider.get_cutout_fixture(
426+
cutout_fixture_name, self._state.deck_definition
427+
)
428+
return cutout_fixture["height"]
384429

385430
def get_slot_definition(self, slot: DeckSlotName) -> SlotDefV3:
386431
"""Get the definition of a slot in the deck."""

api/src/opentrons/protocol_engine/state/geometry.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,8 @@ def get_labware_highest_z(self, labware_id: str) -> float:
8989

9090
return self._get_highest_z_from_labware_data(labware_data)
9191

92-
# TODO(mc, 2022-06-24): rename this method
93-
def get_all_labware_highest_z(self) -> float:
94-
"""Get the highest Z-point across all labware."""
92+
def get_all_obstacle_highest_z(self) -> float:
93+
"""Get the highest Z-point across all obstacles that the instruments need to fly over."""
9594
highest_labware_z = max(
9695
(
9796
self._get_highest_z_from_labware_data(lw_data)
@@ -109,15 +108,33 @@ def get_all_labware_highest_z(self) -> float:
109108
default=0.0,
110109
)
111110

112-
highest_addressable_area_z = max(
113-
(
114-
self._addressable_areas.get_addressable_area_height(area_name)
115-
for area_name in self._addressable_areas.get_all()
116-
),
117-
default=0.0,
118-
)
111+
cutout_fixture_names = self._addressable_areas.get_all_cutout_fixtures()
112+
if cutout_fixture_names is None:
113+
# We're using a simulated deck config (see `Config.use_simulated_deck_config`).
114+
# We only know the addressable areas referenced by the protocol, not the fixtures
115+
# providing them. And there is more than one possible configuration of fixtures
116+
# to provide them. So, we can't know what the highest fixture is. Default to 0.
117+
#
118+
# Defaulting to 0 may not be the right thing to do here.
119+
# For example, suppose a protocol references an addressable area that implies a tall
120+
# fixture must be on the deck, and then it uses long tips that wouldn't be able to
121+
# clear the top of that fixture. We should perhaps raise an analysis error for that,
122+
# but defaulting to 0 here means we won't.
123+
highest_fixture_z = 0.0
124+
else:
125+
highest_fixture_z = max(
126+
(
127+
self._addressable_areas.get_fixture_height(cutout_fixture_name)
128+
for cutout_fixture_name in cutout_fixture_names
129+
),
130+
default=0.0,
131+
)
119132

120-
return max(highest_labware_z, highest_module_z, highest_addressable_area_z)
133+
return max(
134+
highest_labware_z,
135+
highest_module_z,
136+
highest_fixture_z,
137+
)
121138

122139
def get_min_travel_z(
123140
self,
@@ -134,7 +151,7 @@ def get_min_travel_z(
134151
):
135152
min_travel_z = self.get_labware_highest_z(labware_id)
136153
else:
137-
min_travel_z = self.get_all_labware_highest_z()
154+
min_travel_z = self.get_all_obstacle_highest_z()
138155
if minimum_z_height:
139156
min_travel_z = max(min_travel_z, minimum_z_height)
140157
return min_travel_z

api/src/opentrons/protocol_engine/state/motion.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def get_movement_waypoints_to_addressable_area(
160160
# TODO(jbl 11-28-2023) This may need to change for partial tip configurations on a 96
161161
destination_cp = CriticalPoint.XY_CENTER
162162

163-
all_labware_highest_z = self._geometry.get_all_labware_highest_z()
163+
all_labware_highest_z = self._geometry.get_all_obstacle_highest_z()
164164
if minimum_z_height is None:
165165
minimum_z_height = float("-inf")
166166
min_travel_z = max(all_labware_highest_z, minimum_z_height)
@@ -215,7 +215,7 @@ def get_movement_waypoints_to_coords(
215215
Ignored if `direct` is True. If lower than the default height,
216216
the default is used; this can only increase the height, not decrease it.
217217
"""
218-
all_labware_highest_z = self._geometry.get_all_labware_highest_z()
218+
all_labware_highest_z = self._geometry.get_all_obstacle_highest_z()
219219
if additional_min_travel_z is None:
220220
additional_min_travel_z = float("-inf")
221221
min_travel_z = max(all_labware_highest_z, additional_min_travel_z)

api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1552,7 +1552,7 @@ def test_get_highest_z(
15521552
) -> None:
15531553
"""It should return a slot center from engine state."""
15541554
decoy.when(
1555-
mock_engine_client.state.geometry.get_all_labware_highest_z()
1555+
mock_engine_client.state.geometry.get_all_obstacle_highest_z()
15561556
).then_return(9001)
15571557

15581558
result = subject.get_highest_z()

api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def test_initial_state_simulated(
9191
loaded_addressable_areas_by_name={},
9292
potential_cutout_fixtures_by_cutout_id={},
9393
deck_definition=ot3_standard_deck_def,
94+
deck_configuration=[],
9495
use_simulated_deck_config=True,
9596
)
9697

@@ -103,6 +104,7 @@ def test_initial_state(
103104
assert subject.state.potential_cutout_fixtures_by_cutout_id == {}
104105
assert not subject.state.use_simulated_deck_config
105106
assert subject.state.deck_definition == ot3_standard_deck_def
107+
assert subject.state.deck_configuration == _make_deck_config()
106108
# Loading 9 regular slots, 1 trash, 2 Staging Area slots and 3 waste chute types
107109
assert len(subject.state.loaded_addressable_areas_by_name) == 15
108110

api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from opentrons.protocol_engine.types import (
2222
AddressableArea,
2323
AreaType,
24+
DeckConfigurationType,
2425
PotentialCutoutFixture,
2526
Dimensions,
2627
DeckPoint,
@@ -29,8 +30,10 @@
2930

3031

3132
@pytest.fixture(autouse=True)
32-
def patch_mock_move_types(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None:
33-
"""Mock out move_types.py functions."""
33+
def patch_mock_deck_configuration_provider(
34+
decoy: Decoy, monkeypatch: pytest.MonkeyPatch
35+
) -> None:
36+
"""Mock out deck_configuration_provider.py functions."""
3437
for name, func in inspect.getmembers(
3538
deck_configuration_provider, inspect.isfunction
3639
):
@@ -43,6 +46,7 @@ def get_addressable_area_view(
4346
Dict[str, Set[PotentialCutoutFixture]]
4447
] = None,
4548
deck_definition: Optional[DeckDefinitionV4] = None,
49+
deck_configuration: Optional[DeckConfigurationType] = None,
4650
use_simulated_deck_config: bool = False,
4751
) -> AddressableAreaView:
4852
"""Get a labware view test subject."""
@@ -51,12 +55,37 @@ def get_addressable_area_view(
5155
potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id
5256
or {},
5357
deck_definition=deck_definition or cast(DeckDefinitionV4, {"otId": "fake"}),
58+
deck_configuration=deck_configuration or [],
5459
use_simulated_deck_config=use_simulated_deck_config,
5560
)
5661

5762
return AddressableAreaView(state=state)
5863

5964

65+
def test_get_all_cutout_fixtures_simulated_deck_config() -> None:
66+
"""It should return no cutout fixtures when the deck config is simulated."""
67+
subject = get_addressable_area_view(
68+
deck_configuration=None,
69+
use_simulated_deck_config=True,
70+
)
71+
assert subject.get_all_cutout_fixtures() is None
72+
73+
74+
def test_get_all_cutout_fixtures_non_simulated_deck_config() -> None:
75+
"""It should return the cutout fixtures from the deck config, if it's not simulated."""
76+
subject = get_addressable_area_view(
77+
deck_configuration=[
78+
("cutout-id-1", "cutout-fixture-id-1"),
79+
("cutout-id-2", "cutout-fixture-id-2"),
80+
],
81+
use_simulated_deck_config=False,
82+
)
83+
assert subject.get_all_cutout_fixtures() == [
84+
"cutout-fixture-id-1",
85+
"cutout-fixture-id-2",
86+
]
87+
88+
6089
def test_get_loaded_addressable_area() -> None:
6190
"""It should get the loaded addressable area."""
6291
addressable_area = AddressableArea(
@@ -251,6 +280,43 @@ def test_get_addressable_area_center() -> None:
251280
assert result == Point(6, 12, 3)
252281

253282

283+
def test_get_fixture_height(decoy: Decoy) -> None:
284+
"""It should return the height of the requested fixture."""
285+
subject = get_addressable_area_view()
286+
decoy.when(
287+
deck_configuration_provider.get_cutout_fixture(
288+
"someShortCutoutFixture", subject.state.deck_definition
289+
)
290+
).then_return(
291+
{
292+
"height": 10,
293+
# These values don't matter:
294+
"id": "id",
295+
"mayMountTo": [],
296+
"displayName": "",
297+
"providesAddressableAreas": {},
298+
}
299+
)
300+
301+
decoy.when(
302+
deck_configuration_provider.get_cutout_fixture(
303+
"someTallCutoutFixture", subject.state.deck_definition
304+
)
305+
).then_return(
306+
{
307+
"height": 9000.1,
308+
# These values don't matter:
309+
"id": "id",
310+
"mayMountTo": [],
311+
"displayName": "",
312+
"providesAddressableAreas": {},
313+
}
314+
)
315+
316+
assert subject.get_fixture_height("someShortCutoutFixture") == 10
317+
assert subject.get_fixture_height("someTallCutoutFixture") == 9000.1
318+
319+
254320
def test_get_slot_definition() -> None:
255321
"""It should return a deck slot's definition."""
256322
subject = get_addressable_area_view(

0 commit comments

Comments
 (0)