Skip to content

Commit 43bc2b9

Browse files
committed
refactor(api): location sequences for offsets
When we load and express labware offsets, we previously used an object that had explicit named members for a labware parent; a module parent; and a deck slot. This worked as long as we don't have heterogenous labware stacking (to include labware-on-labware-on-adapter) but it's inflexible, and maybe we do want that. Instead, let's have a sequence of little models that specify a parent geometry - a labware def uri, a module model, or an addressable area name - and use that both for specifying offset locations via the HTTP API, for comparing locations internally, and for downloading locations from the HTTP API. Importantly, these offset locations are currently in the database as parts of runs, so we have to keep the old version around when expressing labware data; this is annoying, but that's life. We can calculate the equivalent old data when we load a labware offset pretty easily, and this also lets us not change the legacy protocol core. We also are going to keep the old method of specifying them around, and convert them into the new format, which is also pretty easy, to preserve backwards compatibility and roundtripping older offsets from before this data was present.
1 parent c1305d3 commit 43bc2b9

23 files changed

+1150
-211
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def set_calibration(self, delta: Point) -> None:
122122

123123
request = LabwareOffsetCreate.model_construct(
124124
definitionUri=self.get_uri(),
125-
location=offset_location,
125+
locationSequence=offset_location,
126126
vector=LabwareOffsetVector(x=delta.x, y=delta.y, z=delta.z),
127127
)
128128
self._engine_client.add_labware_offset(request)

api/src/opentrons/protocol_api/core/legacy/labware_offset_provider.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def find(
8585
8686
See the parent class for param details.
8787
"""
88-
offset = self._labware_view.find_applicable_labware_offset(
88+
offset = self._labware_view.find_applicable_labware_offset_by_legacy_location(
8989
definition_uri=load_params.as_uri(),
9090
location=LegacyLabwareOffsetLocation(
9191
slotName=deck_slot,

api/src/opentrons/protocol_engine/actions/actions.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from ..notes.notes import CommandNote
2424
from ..state.update_types import StateUpdate
2525
from ..types import (
26-
LabwareOffsetCreate,
26+
LabwareOffsetCreateInternal,
2727
ModuleDefinition,
2828
Liquid,
2929
DeckConfigurationType,
@@ -206,7 +206,7 @@ class AddLabwareOffsetAction:
206206

207207
labware_offset_id: str
208208
created_at: datetime
209-
request: LabwareOffsetCreate
209+
request: LabwareOffsetCreateInternal
210210

211211

212212
@dataclasses.dataclass(frozen=True)

api/src/opentrons/protocol_engine/errors/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
InvalidLiquidError,
8383
LiquidClassDoesNotExistError,
8484
LiquidClassRedefinitionError,
85+
OffsetLocationInvalidError,
8586
)
8687

8788
from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
@@ -160,6 +161,7 @@
160161
"LocationIsLidDockSlotError",
161162
"InvalidAxisForRobotType",
162163
"NotSupportedOnRobotType",
164+
"OffsetLocationInvalidError",
163165
# error occurrence models
164166
"ErrorOccurrence",
165167
"CommandNotAllowedError",

api/src/opentrons/protocol_engine/errors/exceptions.py

+13
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,19 @@ def __init__(
433433
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
434434

435435

436+
class OffsetLocationInvalidError(ProtocolEngineError):
437+
"""Raised when encountering an invalid labware offset location sequence."""
438+
439+
def __init__(
440+
self,
441+
message: Optional[str] = None,
442+
details: Optional[Dict[str, Any]] = None,
443+
wrapping: Optional[Sequence[EnumeratedError]] = None,
444+
) -> None:
445+
"""Build an OffsetLocationSequenceDoesNotTerminateAtAnAddressableAreaError."""
446+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
447+
448+
436449
class SlotDoesNotExistError(ProtocolEngineError):
437450
"""Raised when referencing a deck slot that does not exist."""
438451

api/src/opentrons/protocol_engine/execution/equipment.py

+3-72
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Equipment command side-effect logic."""
2+
23
from dataclasses import dataclass
34
from typing import Optional, overload, Union, List
45

@@ -42,10 +43,7 @@
4243
from ..types import (
4344
LabwareLocation,
4445
DeckSlotLocation,
45-
ModuleLocation,
46-
OnLabwareLocation,
4746
LabwareOffset,
48-
LegacyLabwareOffsetLocation,
4947
ModuleModel,
5048
ModuleDefinition,
5149
AddressableAreaLocation,
@@ -633,8 +631,9 @@ def find_applicable_labware_offset_id(
633631
or None if no labware offset will apply.
634632
"""
635633
labware_offset_location = (
636-
self._get_labware_offset_location_from_labware_location(labware_location)
634+
self._state_store.geometry.get_projected_offset_location(labware_location)
637635
)
636+
638637
if labware_offset_location is None:
639638
# No offset for off-deck location.
640639
# Returning None instead of raising an exception allows loading a labware
@@ -647,74 +646,6 @@ def find_applicable_labware_offset_id(
647646
)
648647
return self._get_id_from_offset(offset)
649648

650-
def _get_labware_offset_location_from_labware_location(
651-
self, labware_location: LabwareLocation
652-
) -> Optional[LegacyLabwareOffsetLocation]:
653-
if isinstance(labware_location, DeckSlotLocation):
654-
return LegacyLabwareOffsetLocation(slotName=labware_location.slotName)
655-
elif isinstance(labware_location, ModuleLocation):
656-
module_id = labware_location.moduleId
657-
# Allow ModuleNotLoadedError to propagate.
658-
# Note also that we match based on the module's requested model, not its
659-
# actual model, to implement robot-server's documented HTTP API semantics.
660-
module_model = self._state_store.modules.get_requested_model(
661-
module_id=module_id
662-
)
663-
664-
# If `module_model is None`, it probably means that this module was added by
665-
# `ProtocolEngine.use_attached_modules()`, instead of an explicit
666-
# `loadModule` command.
667-
#
668-
# This assert should never raise in practice because:
669-
# 1. `ProtocolEngine.use_attached_modules()` is only used by
670-
# robot-server's "stateless command" endpoints, under `/commands`.
671-
# 2. Those endpoints don't support loading labware, so this code will
672-
# never run.
673-
#
674-
# Nevertheless, if it does happen somehow, we do NOT want to pass the
675-
# `None` value along to `LabwareView.find_applicable_labware_offset()`.
676-
# `None` means something different there, which will cause us to return
677-
# wrong results.
678-
assert module_model is not None, (
679-
"Can't find offsets for labware"
680-
" that are loaded on modules"
681-
" that were loaded with ProtocolEngine.use_attached_modules()."
682-
)
683-
684-
module_location = self._state_store.modules.get_location(
685-
module_id=module_id
686-
)
687-
slot_name = module_location.slotName
688-
return LegacyLabwareOffsetLocation(
689-
slotName=slot_name, moduleModel=module_model
690-
)
691-
elif isinstance(labware_location, OnLabwareLocation):
692-
parent_labware_id = labware_location.labwareId
693-
parent_labware_uri = self._state_store.labware.get_definition_uri(
694-
parent_labware_id
695-
)
696-
697-
base_location = self._state_store.labware.get_parent_location(
698-
parent_labware_id
699-
)
700-
base_labware_offset_location = (
701-
self._get_labware_offset_location_from_labware_location(base_location)
702-
)
703-
if base_labware_offset_location is None:
704-
# No offset for labware sitting on labware off-deck
705-
return None
706-
707-
# If labware is being stacked on itself, all labware in the stack will share a labware offset due to
708-
# them sharing the same definitionUri in `LegacyLabwareOffsetLocation`. This will not be true for the
709-
# bottom-most labware, which will have a `DeckSlotLocation` and have its definitionUri field empty.
710-
return LegacyLabwareOffsetLocation(
711-
slotName=base_labware_offset_location.slotName,
712-
moduleModel=base_labware_offset_location.moduleModel,
713-
definitionUri=parent_labware_uri,
714-
)
715-
else: # Off deck
716-
return None
717-
718649
@staticmethod
719650
def _get_id_from_offset(labware_offset: Optional[LabwareOffset]) -> Optional[str]:
720651
return None if labware_offset is None else labware_offset.id
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Convert labware offset creation requests and stored elements between legacy and new."""
2+
3+
from opentrons_shared_data.robot.types import RobotType
4+
from opentrons_shared_data.deck.types import DeckDefinitionV5
5+
from opentrons.types import DeckSlotName
6+
from .errors import (
7+
OffsetLocationInvalidError,
8+
FixtureDoesNotExistError,
9+
)
10+
from .types import (
11+
LabwareOffsetCreate,
12+
LegacyLabwareOffsetCreate,
13+
LabwareOffsetCreateInternal,
14+
LegacyLabwareOffsetLocation,
15+
LabwareOffsetLocationSequence,
16+
OnLabwareOffsetLocationSequenceComponent,
17+
OnAddressableAreaLocationSequenceComponent,
18+
OnModuleOffsetLocationSequenceComponent,
19+
ModuleModel,
20+
)
21+
from .resources import deck_configuration_provider
22+
23+
24+
def standardize_labware_offset_create(
25+
request: LabwareOffsetCreate | LegacyLabwareOffsetCreate,
26+
robot_type: RobotType,
27+
deck_definition: DeckDefinitionV5,
28+
) -> LabwareOffsetCreateInternal:
29+
"""Turn a union of old and new labware offset create requests into a new one."""
30+
location_sequence, legacy_location = _locations_for_create(
31+
request, robot_type, deck_definition
32+
)
33+
return LabwareOffsetCreateInternal(
34+
definitionUri=request.definitionUri,
35+
locationSequence=location_sequence,
36+
legacyLocation=legacy_location,
37+
vector=request.vector,
38+
)
39+
40+
41+
def _legacy_offset_location_to_offset_location_sequence(
42+
location: LegacyLabwareOffsetLocation, deck_definition: DeckDefinitionV5
43+
) -> LabwareOffsetLocationSequence:
44+
sequence: LabwareOffsetLocationSequence = []
45+
if location.definitionUri:
46+
sequence.append(
47+
OnLabwareOffsetLocationSequenceComponent(labwareUri=location.definitionUri)
48+
)
49+
if location.moduleModel:
50+
sequence.append(
51+
OnModuleOffsetLocationSequenceComponent(moduleModel=location.moduleModel)
52+
)
53+
cutout_id = deck_configuration_provider.get_cutout_id_by_deck_slot_name(
54+
location.slotName
55+
)
56+
possible_cutout_fixture_id = location.moduleModel.value
57+
try:
58+
addressable_area = deck_configuration_provider.get_labware_hosting_addressable_area_name_for_cutout_and_cutout_fixture(
59+
cutout_id, possible_cutout_fixture_id, deck_definition
60+
)
61+
sequence.append(
62+
OnAddressableAreaLocationSequenceComponent(
63+
addressableAreaName=addressable_area
64+
)
65+
)
66+
except FixtureDoesNotExistError:
67+
# this is an OT-2 (or this module isn't supported in the deck definition) and we should use a
68+
# slot addressable area name
69+
sequence.append(
70+
OnAddressableAreaLocationSequenceComponent(
71+
addressableAreaName=location.slotName.value
72+
)
73+
)
74+
75+
else:
76+
# Slight hack: we should have a more formal association here. However, since the slot
77+
# name is already standardized, and since the addressable areas for slots are just the
78+
# name of the slots, we can rely on this.
79+
sequence.append(
80+
OnAddressableAreaLocationSequenceComponent(
81+
addressableAreaName=location.slotName.value
82+
)
83+
)
84+
return sequence
85+
86+
87+
def _offset_location_sequence_to_legacy_offset_location(
88+
location_sequence: LabwareOffsetLocationSequence, deck_definition: DeckDefinitionV5
89+
) -> LegacyLabwareOffsetLocation:
90+
if len(location_sequence) == 0:
91+
raise OffsetLocationInvalidError(
92+
"Offset locations must contain at least one component."
93+
)
94+
last_element = location_sequence[-1]
95+
if not isinstance(last_element, OnAddressableAreaLocationSequenceComponent):
96+
raise OffsetLocationInvalidError(
97+
"Offset locations must end with an addressable area."
98+
)
99+
labware_uri: str | None = None
100+
module_model: ModuleModel | None = None
101+
for location in location_sequence[:-1]:
102+
if isinstance(location, OnAddressableAreaLocationSequenceComponent):
103+
raise OffsetLocationInvalidError(
104+
"Addressable areas may only be the final element of an offset location."
105+
)
106+
elif isinstance(location, OnLabwareOffsetLocationSequenceComponent):
107+
if labware_uri is not None:
108+
# We only take the first location
109+
continue
110+
if module_model is not None:
111+
# Labware can't be underneath modules
112+
raise OffsetLocationInvalidError(
113+
"Labware must not be underneath a module."
114+
)
115+
labware_uri = location.labwareUri
116+
elif isinstance(location, OnModuleOffsetLocationSequenceComponent):
117+
if module_model is not None:
118+
# Bad, somebody put more than one module in here
119+
raise OffsetLocationInvalidError(
120+
"Only one module location may exist in an offset location."
121+
)
122+
module_model = location.moduleModel
123+
else:
124+
raise OffsetLocationInvalidError(
125+
f"Invalid location component in offset location: {repr(location)}"
126+
)
127+
(
128+
cutout_id,
129+
cutout_fixtures,
130+
) = deck_configuration_provider.get_potential_cutout_fixtures(
131+
last_element.addressableAreaName, deck_definition
132+
)
133+
slot_name = deck_configuration_provider.get_deck_slot_for_cutout_id(cutout_id)
134+
return LegacyLabwareOffsetLocation(
135+
slotName=slot_name, moduleModel=module_model, definitionUri=labware_uri
136+
)
137+
138+
139+
def _locations_for_create(
140+
request: LabwareOffsetCreate | LegacyLabwareOffsetCreate,
141+
robot_type: RobotType,
142+
deck_definition: DeckDefinitionV5,
143+
) -> tuple[LabwareOffsetLocationSequence, LegacyLabwareOffsetLocation]:
144+
if isinstance(request, LabwareOffsetCreate):
145+
return (
146+
request.locationSequence,
147+
_offset_location_sequence_to_legacy_offset_location(
148+
request.locationSequence, deck_definition
149+
),
150+
)
151+
else:
152+
normalized = request.location.model_copy(
153+
update={
154+
"slotName": request.location.slotName.to_equivalent_for_robot_type(
155+
robot_type
156+
)
157+
}
158+
)
159+
return (
160+
_legacy_offset_location_to_offset_location_sequence(
161+
normalized, deck_definition
162+
),
163+
normalized,
164+
)

api/src/opentrons/protocol_engine/protocol_engine.py

+13-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""ProtocolEngine class definition."""
2+
23
from contextlib import AsyncExitStack
34
from logging import getLogger
45
from typing import Dict, Optional, Union, AsyncGenerator, Callable
@@ -20,11 +21,12 @@
2021
from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError
2122
from .errors.exceptions import EStopActivatedError
2223
from .error_recovery_policy import ErrorRecoveryPolicy
23-
from . import commands, slot_standardization
24+
from . import commands, slot_standardization, labware_offset_standardization
2425
from .resources import ModelUtils, ModuleDataProvider, FileProvider
2526
from .types import (
2627
LabwareOffset,
2728
LabwareOffsetCreate,
29+
LegacyLabwareOffsetCreate,
2830
LabwareUri,
2931
ModuleModel,
3032
Liquid,
@@ -517,15 +519,21 @@ async def finish(
517519
)
518520
)
519521

520-
def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset:
522+
def add_labware_offset(
523+
self, request: LabwareOffsetCreate | LegacyLabwareOffsetCreate
524+
) -> LabwareOffset:
521525
"""Add a new labware offset and return it.
522526
523527
The added offset will apply to subsequent `LoadLabwareCommand`s.
524528
525529
To retrieve offsets later, see `.state_view.labware`.
526530
"""
527-
request = slot_standardization.standardize_labware_offset(
528-
request, self.state_view.config.robot_type
531+
internal_request = (
532+
labware_offset_standardization.standardize_labware_offset_create(
533+
request,
534+
self.state_view.config.robot_type,
535+
self.state_view.addressable_areas.deck_definition,
536+
)
529537
)
530538

531539
labware_offset_id = self._model_utils.generate_id()
@@ -534,7 +542,7 @@ def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset:
534542
AddLabwareOffsetAction(
535543
labware_offset_id=labware_offset_id,
536544
created_at=created_at,
537-
request=request,
545+
request=internal_request,
538546
)
539547
)
540548
return self.state_view.labware.get_labware_offset(

0 commit comments

Comments
 (0)