Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(api,robot-server,shared-data): Split LabwareDefinition Pydantic model for labware schemas 2 and 3 #17563

Merged
merged 9 commits into from
Feb 21, 2025
6 changes: 4 additions & 2 deletions api/src/opentrons/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
Union,
)

from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import (
labware_definition_type_adapter,
)
from opentrons_shared_data.robot.types import RobotType

from opentrons import protocol_api, __version__, should_use_ot3
Expand Down Expand Up @@ -560,7 +562,7 @@ def _create_live_context_pe(
# Non-async would use call_soon_threadsafe(), which makes the waiting harder.
async def add_all_extra_labware() -> None:
for labware_definition_dict in extra_labware.values():
labware_definition = LabwareDefinition.model_validate(
labware_definition = labware_definition_type_adapter.validate_python(
labware_definition_dict
)
pe.add_labware_definition(labware_definition)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from opentrons.types import Mount, Point
from opentrons.hardware_control.types import OT3Mount

from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import LabwareDefinition2

if typing.TYPE_CHECKING:
from opentrons_shared_data.pipette.types import LabwareUri
Expand Down Expand Up @@ -119,12 +119,9 @@ def save_pipette_offset_calibration(
# TODO (lc 09-26-2022) We should ensure that only LabwareDefinition models are passed
# into this function instead of a mixture of TypeDicts and BaseModels
def load_tip_length_for_pipette(
pipette_id: str, tiprack: typing.Union["TypeDictLabwareDef2", LabwareDefinition]
pipette_id: str, tiprack: typing.Union["TypeDictLabwareDef2", LabwareDefinition2]
) -> TipLengthCalibration:
if isinstance(tiprack, LabwareDefinition):
# todo(mm, 2025-02-13): This is only correct for schema 2 labware.
# The LabwareDefinition union member needs to be narrowed to LabwareDefinition2,
# which doesn't exist yet (https://opentrons.atlassian.net/browse/EXEC-1206).
if isinstance(tiprack, LabwareDefinition2):
tiprack = typing.cast(
"TypeDictLabwareDef2",
tiprack.model_dump(
Expand Down
6 changes: 4 additions & 2 deletions api/src/opentrons/protocol_api/core/engine/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from opentrons.protocol_engine.commands import LoadModuleResult

from opentrons_shared_data.deck.types import DeckDefinitionV5, SlotDefV3
from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import (
labware_definition_type_adapter,
)
from opentrons_shared_data.labware.types import LabwareDefinition as LabwareDefDict
from opentrons_shared_data import liquid_classes
from opentrons_shared_data.liquid_classes.liquid_class_definition import (
Expand Down Expand Up @@ -196,7 +198,7 @@ def add_labware_definition(
) -> LabwareLoadParams:
"""Add a labware definition to the set of loadable definitions."""
uri = self._engine_client.add_labware_definition(
LabwareDefinition.model_validate(definition)
labware_definition_type_adapter.validate_python(definition)
)
return LabwareLoadParams.from_uri(uri)

Expand Down
16 changes: 13 additions & 3 deletions api/src/opentrons/protocol_api/core/engine/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,27 @@ def labware_id(self) -> str:
@property
def diameter(self) -> Optional[float]:
"""Get the well's diameter, if circular."""
return self._definition.diameter
return (
self._definition.diameter if self._definition.shape == "circular" else None
)

@property
def length(self) -> Optional[float]:
"""Get the well's length, if rectangular."""
return self._definition.xDimension
return (
self._definition.xDimension
if self._definition.shape == "rectangular"
else None
)

@property
def width(self) -> Optional[float]:
"""Get the well's width, if rectangular."""
return self._definition.yDimension
return (
self._definition.yDimension
if self._definition.shape == "rectangular"
else None
)

@property
def depth(self) -> float:
Expand Down
40 changes: 28 additions & 12 deletions api/src/opentrons/protocol_engine/commands/load_labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@

from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
from typing_extensions import Literal, TypeGuard
from typing_extensions import Literal, TypeGuard, assert_type

from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import (
LabwareDefinition,
LabwareDefinition2,
LabwareDefinition3,
)

from ..errors import LabwareIsNotAllowedInLocationError
from ..resources import labware_validation, fixture_validation
Expand Down Expand Up @@ -184,17 +188,29 @@ async def execute( # noqa: C901
bottom_labware_id=verified_location.labwareId,
)
# Validate load location is valid for lids
if (
labware_validation.validate_definition_is_lid(
definition=loaded_labware.definition
)
and loaded_labware.definition.compatibleParentLabware is not None
and self._state_view.labware.get_load_name(verified_location.labwareId)
not in loaded_labware.definition.compatibleParentLabware
if labware_validation.validate_definition_is_lid(
definition=loaded_labware.definition
):
raise ValueError(
f"Labware Lid {params.loadName} may not be loaded on parent labware {self._state_view.labware.get_display_name(verified_location.labwareId)}."
)
# This parent is assumed to be compatible, unless the lid enumerates
# all its compatible parents and this parent is missing from the list.
if isinstance(loaded_labware.definition, LabwareDefinition2):
# Labware schema 2 has no compatibleParentLabware list.
parent_is_incompatible = False
else:
assert_type(loaded_labware.definition, LabwareDefinition3)
parent_is_incompatible = (
loaded_labware.definition.compatibleParentLabware is not None
and self._state_view.labware.get_load_name(
verified_location.labwareId
)
not in loaded_labware.definition.compatibleParentLabware
)

if parent_is_incompatible:
raise ValueError(
f"Labware Lid {params.loadName} may not be loaded on parent labware"
f" {self._state_view.labware.get_display_name(verified_location.labwareId)}."
)

# Validate labware for the absorbance reader
if self._is_loading_to_module(
Expand Down
30 changes: 20 additions & 10 deletions api/src/opentrons/protocol_engine/execution/equipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

from dataclasses import dataclass
from typing import Optional, overload, List
from typing_extensions import assert_type

from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import (
LabwareDefinition,
LabwareDefinition2,
LabwareDefinition3,
)
from opentrons_shared_data.pipette.types import PipetteNameType

from opentrons.calibration_storage.helpers import uri_from_details
Expand Down Expand Up @@ -406,7 +411,7 @@ async def load_module(
definition=attached_module.definition,
)

async def load_lids(
async def load_lids( # noqa: C901
self,
load_name: str,
namespace: str,
Expand Down Expand Up @@ -454,18 +459,23 @@ async def load_lids(
f"Requested quantity {quantity} is greater than the stack limit of {stack_limit} provided by definition for {load_name}."
)

# Allow propagation of ModuleNotLoadedError.
if (
isinstance(location, DeckSlotLocation)
and definition.parameters.isDeckSlotCompatible is not None
and not definition.parameters.isDeckSlotCompatible
):
if isinstance(definition, LabwareDefinition2):
is_deck_slot_compatible = True
else:
assert_type(definition, LabwareDefinition3)
is_deck_slot_compatible = (
True
if definition.parameters.isDeckSlotCompatible is None
else definition.parameters.isDeckSlotCompatible
)

if isinstance(location, DeckSlotLocation) and not is_deck_slot_compatible:
raise ValueError(
f"Lid Labware {load_name} cannot be loaded onto a Deck Slot."
)

load_labware_data_list = []
ids = []
load_labware_data_list: list[LoadedLabwareData] = []
ids: list[str] = []
if labware_ids is not None:
if len(labware_ids) < quantity:
raise ValueError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import logging
from anyio import to_thread

from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.labware.labware_definition import (
LabwareDefinition,
LabwareDefinition3,
labware_definition_type_adapter,
)

from opentrons.protocols.labware import get_labware_definition

Expand Down Expand Up @@ -45,7 +49,7 @@ async def get_labware_definition(
def _get_labware_definition_sync(
load_name: str, namespace: str, version: int
) -> LabwareDefinition:
return LabwareDefinition.model_validate(
return labware_definition_type_adapter.validate_python(
get_labware_definition(load_name, namespace, version)
)

Expand Down Expand Up @@ -73,15 +77,30 @@ def _get_calibrated_tip_length_sync(
labware_definition: LabwareDefinition,
nominal_fallback: float,
) -> float:
try:
return instr_cal.load_tip_length_for_pipette(
pipette_serial, labware_definition
).tip_length

except TipLengthCalNotFound as e:
message = (
f"No calibrated tip length found for {pipette_serial},"
f" using nominal fallback value of {nominal_fallback}"
if isinstance(labware_definition, LabwareDefinition3):
# FIXME(mm, 2025-02-19): This needs to be resolved for v8.4.0.
# Tip length calibration internals don't yet support schema 3 because
# it's probably an incompatible change at the filesystem level
# (not downgrade-safe), and because robot-server's calibration sessions
# are built atop opentrons.protocol_api.core.legacy, which does not (yet?)
# support labware schema 3.
# https://opentrons.atlassian.net/browse/EXEC-1230
log.warning(
f"Tip rack"
f" {labware_definition.namespace}/{labware_definition.parameters.loadName}/{labware_definition.version}"
f" has schema 3, so tip length calibration is currently unsupported."
f" Using nominal fallback of {nominal_fallback}."
)
log.debug(message, exc_info=e)
return nominal_fallback
else:
try:
return instr_cal.load_tip_length_for_pipette(
pipette_serial, labware_definition
).tip_length
except TipLengthCalNotFound as e:
message = (
f"No calibrated tip length found for {pipette_serial},"
f" using nominal fallback value of {nominal_fallback}"
)
log.debug(message, exc_info=e)
return nominal_fallback
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up for this temporary hack that I'm doing for OT-2 tip length calibration.

The ticket I linked, EXEC-1230, currently talks about totally blocking these calibration flows from dealing with labware schema 3. I'm changing my mind on that—I think we have to get them to deal with labware schema 3 correctly somehow—and I'll rewrite EXEC-1230 when I know more about the approach.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think we have to get them to deal with labware schema 3 correctly? We could instead not ever issue OT-2 tiprack or calibration block definitions in schema 3. We possibly lock out loading tipracks in schema 3 when the robot is an OT-2, for this reason. This means that OT-2 tipracks wouldn't get the benefit of the more-correct geometry loading, but this is an ancient and known problem with OT-2 tipracks that people have been handing for years.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think we have to get them to deal with labware schema 3 correctly?

Basically because I didn't feel it was acceptable to abandon OT-2 tip racks like that. If our goal is to improve labware positioning, but we fail at standard OT-2 tip racks, then that's a pretty big bummer. They're certainly not edge cases. I think it's worth trying to resolve even if it means us (or me) pushing a little harder.

Locking OT-2 tip racks and calibration blocks to schema 2 sounds like a good escape hatch, if it comes down to it, but I hope it doesn't come down to it.

I can deprioritize this and revisit the question later, but mark it as something we must resolve somehow before v8.4.0—either get it to work with schema 3, or delete the schema 3 defs and lock it to schema 2.

2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ def get_nominal_tip_geometry(

return TipGeometry(
length=effective_length,
diameter=well_def.diameter, # type: ignore[arg-type]
diameter=well_def.diameter,
# TODO(mc, 2020-11-12): WellDefinition type says totalLiquidVolume
# is a float, but hardware controller expects an int
volume=int(well_def.totalLiquidVolume),
Expand Down
Loading
Loading