diff --git a/api/src/opentrons/protocol_api/_liquid.py b/api/src/opentrons/protocol_api/_liquid.py index f5a6029e19d..f1f00d858be 100644 --- a/api/src/opentrons/protocol_api/_liquid.py +++ b/api/src/opentrons/protocol_api/_liquid.py @@ -4,7 +4,7 @@ from typing import Optional, Dict, Union, TYPE_CHECKING, Tuple from opentrons_shared_data.liquid_classes.liquid_class_definition import ( - LiquidClassSchemaV1, + LiquidClassSchema, ) from opentrons.protocols.advanced_control.transfers.common import ( @@ -47,7 +47,7 @@ class LiquidClass: _by_pipette_setting: Dict[str, Dict[str, TransferProperties]] @classmethod - def create(cls, liquid_class_definition: LiquidClassSchemaV1) -> "LiquidClass": + def create(cls, liquid_class_definition: LiquidClassSchema) -> "LiquidClass": """Liquid class factory method.""" by_pipette_settings: Dict[str, Dict[str, TransferProperties]] = {} diff --git a/api/src/opentrons/protocol_api/_liquid_properties.py b/api/src/opentrons/protocol_api/_liquid_properties.py index 071ecc63007..ad67065b1d5 100644 --- a/api/src/opentrons/protocol_api/_liquid_properties.py +++ b/api/src/opentrons/protocol_api/_liquid_properties.py @@ -110,17 +110,38 @@ class TipPosition: _position_reference: PositionReference _offset: Coordinate + @classmethod + def create_offset( + cls, new_offset: Union[Sequence[float], Coordinate] + ) -> Coordinate: + if isinstance(new_offset, Coordinate): + new_coordinate: Sequence[Union[int, float]] = [ + new_offset.x, + new_offset.y, + new_offset.z, + ] + else: + new_coordinate = new_offset + x, y, z = validation.validate_coordinates(new_coordinate) + return Coordinate(x=x, y=y, z=z) + + @classmethod + def create_position_reference( + cls, new_position: Union[str, PositionReference] + ) -> PositionReference: + return ( + new_position + if isinstance(new_position, PositionReference) + else PositionReference(new_position) + ) + @property def position_reference(self) -> PositionReference: return self._position_reference @position_reference.setter def position_reference(self, new_position: Union[str, PositionReference]) -> None: - self._position_reference = ( - new_position - if isinstance(new_position, PositionReference) - else PositionReference(new_position) - ) + self._position_reference = TipPosition.create_position_reference(new_position) @property def offset(self) -> Coordinate: @@ -128,16 +149,7 @@ def offset(self) -> Coordinate: @offset.setter def offset(self, new_offset: Union[Sequence[float], Coordinate]) -> None: - if isinstance(new_offset, Coordinate): - new_coordinate: Sequence[Union[int, float]] = [ - new_offset.x, - new_offset.y, - new_offset.z, - ] - else: - new_coordinate = new_offset - x, y, z = validation.validate_coordinates(new_coordinate) - self._offset = Coordinate(x=x, y=y, z=z) + self._offset = TipPosition.create_offset(new_offset) def as_shared_data_model(self) -> SharedDataTipPosition: return SharedDataTipPosition( @@ -145,6 +157,12 @@ def as_shared_data_model(self) -> SharedDataTipPosition: offset=self.offset, ) + @classmethod + def maybe_as_shared_data_model( + cls, position: Optional["TipPosition"] + ) -> Optional[SharedDataTipPosition]: + return position.as_shared_data_model() if position else None + @dataclass(slots=True) class DelayProperties: @@ -489,14 +507,29 @@ def delay(self) -> DelayProperties: class AspirateProperties(_BaseLiquidHandlingProperties): _aspirate_position: TipPosition + _aspirate_end_position: Optional[TipPosition] _retract: RetractAspirate _pre_wet: bool _mix: MixProperties + _override_aspirate_position: Optional[TipPosition] + _override_aspirate_end_position: Optional[TipPosition] @property def aspirate_position(self) -> TipPosition: + return self._override_aspirate_position or self._aspirate_position + + @property + def aspirate_end_position(self) -> Optional[TipPosition]: + return self._override_aspirate_end_position or self._aspirate_end_position + + @property + def fallback_aspirate_position(self) -> TipPosition: return self._aspirate_position + @property + def fallback_aspirate_end_position(self) -> Optional[TipPosition]: + return self._aspirate_end_position + @property def pre_wet(self) -> bool: return self._pre_wet @@ -519,6 +552,15 @@ def as_shared_data_model(self) -> SharedDataAspirateProperties: submerge=self._submerge.as_shared_data_model(), retract=self._retract.as_shared_data_model(), aspiratePosition=self._aspirate_position.as_shared_data_model(), + aspirateEndPosition=TipPosition.maybe_as_shared_data_model( + self._aspirate_end_position + ), + overrideAspiratePosition=TipPosition.maybe_as_shared_data_model( + self._override_aspirate_position + ), + overrideAspirateEndPosition=TipPosition.maybe_as_shared_data_model( + self._override_aspirate_end_position + ), flowRateByVolume=self._flow_rate_by_volume.as_list_of_tuples(), preWet=self._pre_wet, mix=self._mix.as_shared_data_model(), @@ -526,19 +568,56 @@ def as_shared_data_model(self) -> SharedDataAspirateProperties: correctionByVolume=self._correction_by_volume.as_list_of_tuples(), ) + def override_tip_positions( + self, + new_position: Union[str, PositionReference], + new_offset: Union[Sequence[float], Coordinate], + new_end_position: Optional[Union[str, PositionReference]] = None, + new_end_offset: Optional[Union[Sequence[float], Coordinate]] = None, + ) -> None: + self._override_aspirate_position = TipPosition( + TipPosition.create_position_reference(new_position), + TipPosition.create_offset(new_offset), + ) + if (new_end_position is None) != (new_end_offset is None): + # they must either both be none or both defined + raise ValueError( + f"New end position and new end offset must both be specified got position {new_end_position} and offset {new_end_offset}" + ) + if new_end_position and new_end_offset: + self._override_aspirate_end_position = TipPosition( + TipPosition.create_position_reference(new_end_position), + TipPosition.create_offset(new_end_offset), + ) + @dataclass(slots=True) class SingleDispenseProperties(_BaseLiquidHandlingProperties): _dispense_position: TipPosition + _dispense_end_position: Optional[TipPosition] _retract: RetractDispense _push_out_by_volume: LiquidHandlingPropertyByVolume _mix: MixProperties + _override_dispense_position: Optional[TipPosition] + _override_dispense_end_position: Optional[TipPosition] @property def dispense_position(self) -> TipPosition: + return self._override_dispense_position or self._dispense_position + + @property + def dispense_end_position(self) -> Optional[TipPosition]: + return self._override_dispense_end_position or self._dispense_end_position + + @property + def fallback_dispense_position(self) -> TipPosition: return self._dispense_position + @property + def fallback_dispense_end_position(self) -> Optional[TipPosition]: + return self._dispense_end_position + @property def push_out_by_volume(self) -> LiquidHandlingPropertyByVolume: return self._push_out_by_volume @@ -556,6 +635,15 @@ def as_shared_data_model(self) -> SharedDataSingleDispenseProperties: submerge=self._submerge.as_shared_data_model(), retract=self._retract.as_shared_data_model(), dispensePosition=self._dispense_position.as_shared_data_model(), + dispenseEndPosition=TipPosition.maybe_as_shared_data_model( + self._dispense_end_position + ), + overrideDispensePosition=TipPosition.maybe_as_shared_data_model( + self._override_dispense_position + ), + overrideDispenseEndPosition=TipPosition.maybe_as_shared_data_model( + self._override_dispense_end_position + ), flowRateByVolume=self._flow_rate_by_volume.as_list_of_tuples(), mix=self._mix.as_shared_data_model(), pushOutByVolume=self._push_out_by_volume.as_list_of_tuples(), @@ -563,19 +651,56 @@ def as_shared_data_model(self) -> SharedDataSingleDispenseProperties: correctionByVolume=self._correction_by_volume.as_list_of_tuples(), ) + def override_tip_positions( + self, + new_position: Union[str, PositionReference], + new_offset: Union[Sequence[float], Coordinate], + new_end_position: Optional[Union[str, PositionReference]] = None, + new_end_offset: Optional[Union[Sequence[float], Coordinate]] = None, + ) -> None: + self._override_dispense_position = TipPosition( + TipPosition.create_position_reference(new_position), + TipPosition.create_offset(new_offset), + ) + if (new_end_position is None) != (new_end_offset is None): + # they must either both be none or both defined + raise ValueError( + f"New end position and new end offset must both be specified got position {new_end_position} and offset {new_end_offset}" + ) + if new_end_position and new_end_offset: + self._override_dispense_end_position = TipPosition( + TipPosition.create_position_reference(new_end_position), + TipPosition.create_offset(new_end_offset), + ) + @dataclass(slots=True) class MultiDispenseProperties(_BaseLiquidHandlingProperties): _dispense_position: TipPosition + _dispense_end_position: Optional[TipPosition] _retract: RetractDispense _conditioning_by_volume: LiquidHandlingPropertyByVolume _disposal_by_volume: LiquidHandlingPropertyByVolume + _override_dispense_position: Optional[TipPosition] + _override_dispense_end_position: Optional[TipPosition] @property def dispense_position(self) -> TipPosition: + return self._override_dispense_position or self._dispense_position + + @property + def dispense_end_position(self) -> Optional[TipPosition]: + return self._override_dispense_end_position or self._dispense_end_position + + @property + def fallback_dispense_position(self) -> TipPosition: return self._dispense_position + @property + def fallback_dispense_end_position(self) -> Optional[TipPosition]: + return self._dispense_end_position + @property def retract(self) -> RetractDispense: return self._retract @@ -593,6 +718,15 @@ def as_shared_data_model(self) -> SharedDataMultiDispenseProperties: submerge=self._submerge.as_shared_data_model(), retract=self._retract.as_shared_data_model(), dispensePosition=self._dispense_position.as_shared_data_model(), + dispenseEndPosition=TipPosition.maybe_as_shared_data_model( + self._dispense_end_position + ), + overrideDispensePosition=TipPosition.maybe_as_shared_data_model( + self._override_dispense_position + ), + overrideDispenseEndPosition=TipPosition.maybe_as_shared_data_model( + self._override_dispense_end_position + ), flowRateByVolume=self._flow_rate_by_volume.as_list_of_tuples(), conditioningByVolume=self._conditioning_by_volume.as_list_of_tuples(), disposalByVolume=self._disposal_by_volume.as_list_of_tuples(), @@ -600,6 +734,28 @@ def as_shared_data_model(self) -> SharedDataMultiDispenseProperties: correctionByVolume=self._correction_by_volume.as_list_of_tuples(), ) + def override_tip_positions( + self, + new_position: Union[str, PositionReference], + new_offset: Union[Sequence[float], Coordinate], + new_end_position: Optional[Union[str, PositionReference]] = None, + new_end_offset: Optional[Union[Sequence[float], Coordinate]] = None, + ) -> None: + self._override_dispense_position = TipPosition( + TipPosition.create_position_reference(new_position), + TipPosition.create_offset(new_offset), + ) + if (new_end_position is None) != (new_end_offset is None): + # they must either both be none or both defined + raise ValueError( + f"New end position and new end offset must both be specified got position {new_end_position} and offset {new_end_offset}" + ) + if new_end_position and new_end_offset: + self._override_dispense_end_position = TipPosition( + TipPosition.create_position_reference(new_end_position), + TipPosition.create_offset(new_end_offset), + ) + @dataclass(slots=True) class TransferProperties: @@ -629,6 +785,12 @@ def _build_tip_position(tip_position: SharedDataTipPosition) -> TipPosition: ) +def _maybe_build_tip_position( + tip_position: Optional[SharedDataTipPosition], +) -> Optional[TipPosition]: + return _build_tip_position(tip_position) if tip_position else None + + def _build_delay_properties( delay_properties: SharedDataDelayProperties, ) -> DelayProperties: @@ -732,6 +894,15 @@ def build_aspirate_properties( _submerge=_build_submerge(aspirate_properties.submerge), _retract=_build_retract_aspirate(aspirate_properties.retract), _aspirate_position=_build_tip_position(aspirate_properties.aspiratePosition), + _aspirate_end_position=_maybe_build_tip_position( + aspirate_properties.aspirateEndPosition + ), + _override_aspirate_position=_maybe_build_tip_position( + aspirate_properties.overrideAspiratePosition + ), + _override_aspirate_end_position=_maybe_build_tip_position( + aspirate_properties.overrideAspirateEndPosition + ), _flow_rate_by_volume=LiquidHandlingPropertyByVolume( aspirate_properties.flowRateByVolume ), @@ -753,6 +924,15 @@ def build_single_dispense_properties( _dispense_position=_build_tip_position( single_dispense_properties.dispensePosition ), + _dispense_end_position=_maybe_build_tip_position( + single_dispense_properties.dispenseEndPosition + ), + _override_dispense_position=_maybe_build_tip_position( + single_dispense_properties.overrideDispensePosition + ), + _override_dispense_end_position=_maybe_build_tip_position( + single_dispense_properties.overrideDispenseEndPosition + ), _flow_rate_by_volume=LiquidHandlingPropertyByVolume( single_dispense_properties.flowRateByVolume ), @@ -778,6 +958,15 @@ def build_multi_dispense_properties( _dispense_position=_build_tip_position( multi_dispense_properties.dispensePosition ), + _dispense_end_position=_maybe_build_tip_position( + multi_dispense_properties.dispenseEndPosition + ), + _override_dispense_position=_maybe_build_tip_position( + multi_dispense_properties.overrideDispensePosition + ), + _override_dispense_end_position=_maybe_build_tip_position( + multi_dispense_properties.overrideDispenseEndPosition + ), _flow_rate_by_volume=LiquidHandlingPropertyByVolume( multi_dispense_properties.flowRateByVolume ), diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 812ab61d12a..d8949096dd7 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -20,6 +20,7 @@ NozzleConfigurationType, NozzleMapInterface, MeniscusTrackingTarget, + Point, ) from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.dev_types import PipetteDict @@ -74,7 +75,11 @@ UnsupportedHardwareCommand, CommandPreconditionViolated, ) -from opentrons_shared_data.liquid_classes.liquid_class_definition import BlowoutLocation +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + BlowoutLocation, + PositionReference, + Coordinate, +) from opentrons.protocol_api._nozzle_layout import NozzleLayout from . import overlap_versions, pipette_movement_conflict from . import transfer_components_executor as tx_comps_executor @@ -2184,21 +2189,46 @@ def aspirate_liquid_class( ): self.prepare_to_aspirate() - aspirate_point = ( - tx_comps_executor.absolute_point_from_position_reference_and_offset( + aspirate_point = tx_comps_executor.absolute_point_from_position_reference_and_offset( + well=source_well, + well_volume_difference=-volume, + position_reference=aspirate_props.aspirate_position.position_reference, + offset=aspirate_props.aspirate_position.offset, + mount=self.get_mount(), + fallback_position_reference=aspirate_props.fallback_aspirate_position.position_reference, + fallback_offset=aspirate_props.fallback_aspirate_position.offset, + ) + aspirate_end_point: Optional[Point] = None + aspirate_end_location: Optional[Location] = None + if aspirate_props.aspirate_end_position: + fb_aspirate_end_location: Optional[PositionReference] = None + fb_aspirate_end_offset: Optional[Coordinate] = None + if aspirate_props.fallback_aspirate_end_position: + fb_aspirate_end_location = ( + aspirate_props.fallback_aspirate_end_position.position_reference + ) + fb_aspirate_end_offset = ( + aspirate_props.fallback_aspirate_end_position.offset + ) + aspirate_end_point = tx_comps_executor.absolute_point_from_position_reference_and_offset( well=source_well, well_volume_difference=-volume, - position_reference=aspirate_props.aspirate_position.position_reference, - offset=aspirate_props.aspirate_position.offset, + position_reference=aspirate_props.aspirate_end_position.position_reference, + offset=aspirate_props.aspirate_end_position.offset, mount=self.get_mount(), + fallback_position_reference=fb_aspirate_end_location, + fallback_offset=fb_aspirate_end_offset, + ) + aspirate_end_location = Location( + aspirate_end_point, labware=source_loc.labware ) - ) aspirate_location = Location(aspirate_point, labware=source_loc.labware) components_executor = tx_comps_executor.TransferComponentsExecutor( instrument_core=self, transfer_properties=transfer_properties, target_location=aspirate_location, + target_end_location=aspirate_end_location, target_well=source_well, transfer_type=transfer_type, tip_state=tx_comps_executor.TipState( @@ -2331,12 +2361,39 @@ def dispense_liquid_class( position_reference=dispense_props.dispense_position.position_reference, offset=dispense_props.dispense_position.offset, mount=self.get_mount(), + fallback_position_reference=dispense_props.fallback_dispense_position.position_reference, + fallback_offset=dispense_props.fallback_dispense_position.offset, ) dispense_location = Location(dispense_point, labware=dest_loc.labware) else: dispense_location = dest dest_well = None + dispense_end_location: Optional[Location] = None + if dispense_props.dispense_end_position: + assert dest_well is not None + fb_dispense_end_location: Optional[PositionReference] = None + fb_dispense_end_offset: Optional[Coordinate] = None + if dispense_props.fallback_dispense_end_position: + fb_dispense_end_location = ( + dispense_props.fallback_dispense_end_position.position_reference + ) + fb_dispense_end_offset = ( + dispense_props.fallback_dispense_end_position.offset + ) + dispense_end_point = tx_comps_executor.absolute_point_from_position_reference_and_offset( + well=dest_well, + well_volume_difference=volume, + position_reference=dispense_props.dispense_end_position.position_reference, + offset=dispense_props.dispense_end_position.offset, + mount=self.get_mount(), + fallback_position_reference=fb_dispense_end_location, + fallback_offset=fb_dispense_end_offset, + ) + dispense_end_location = Location( + dispense_end_point, labware=dest_loc.labware + ) + last_liquid_and_airgap_in_tip = ( tip_contents[-1] if tip_contents @@ -2349,6 +2406,7 @@ def dispense_liquid_class( instrument_core=self, transfer_properties=transfer_properties, target_location=dispense_location, + target_end_location=dispense_end_location, target_well=dest_well, transfer_type=transfer_type, tip_state=tx_comps_executor.TipState( @@ -2410,16 +2468,41 @@ def dispense_liquid_class_during_multi_dispense( dispense_props = transfer_properties.multi_dispense dest_loc, dest_well = dest - dispense_point = ( - tx_comps_executor.absolute_point_from_position_reference_and_offset( + dispense_point = tx_comps_executor.absolute_point_from_position_reference_and_offset( + well=dest_well, + well_volume_difference=volume, + position_reference=dispense_props.dispense_position.position_reference, + offset=dispense_props.dispense_position.offset, + mount=self.get_mount(), + fallback_position_reference=dispense_props.fallback_dispense_position.position_reference, + fallback_offset=dispense_props.fallback_dispense_position.offset, + ) + dispense_location = Location(dispense_point, labware=dest_loc.labware) + + dispense_end_location: Optional[Location] = None + if dispense_props.dispense_end_position: + fb_dispense_end_location: Optional[PositionReference] = None + fb_dispense_end_offset: Optional[Coordinate] = None + if dispense_props.fallback_dispense_end_position: + fb_dispense_end_location = ( + dispense_props.fallback_dispense_end_position.position_reference + ) + fb_dispense_end_offset = ( + dispense_props.fallback_dispense_end_position.offset + ) + dispense_end_point = tx_comps_executor.absolute_point_from_position_reference_and_offset( well=dest_well, well_volume_difference=volume, - position_reference=dispense_props.dispense_position.position_reference, - offset=dispense_props.dispense_position.offset, + position_reference=dispense_props.dispense_end_position.position_reference, + offset=dispense_props.dispense_end_position.offset, mount=self.get_mount(), + fallback_position_reference=fb_dispense_end_location, + fallback_offset=fb_dispense_end_offset, ) - ) - dispense_location = Location(dispense_point, labware=dest_loc.labware) + dispense_end_location = Location( + dispense_end_point, labware=dest_loc.labware + ) + last_liquid_and_airgap_in_tip = ( tip_contents[-1] if tip_contents @@ -2432,6 +2515,7 @@ def dispense_liquid_class_during_multi_dispense( instrument_core=self, transfer_properties=transfer_properties, target_location=dispense_location, + target_end_location=dispense_end_location, target_well=dest_well, transfer_type=transfer_type, tip_state=tx_comps_executor.TipState( diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index f04b7131147..29a265ba738 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -11,7 +11,7 @@ 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 ( - LiquidClassSchemaV1, + LiquidClassSchema, ) from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.robot.types import RobotType @@ -124,7 +124,7 @@ def __init__( str, Union[ModuleCore, NonConnectedModuleCore] ] = {} self._disposal_locations: List[Union[Labware, TrashBin, WasteChute]] = [] - self._liquid_class_def_cache: Dict[Tuple[str, int], LiquidClassSchemaV1] = {} + self._liquid_class_def_cache: Dict[Tuple[str, int], LiquidClassSchema] = {} self._load_fixed_trash() @property diff --git a/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py b/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py index 7f34e874d38..919f321ac10 100644 --- a/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py +++ b/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py @@ -29,6 +29,7 @@ from opentrons.protocols.advanced_control.transfers import ( transfer_liquid_utils as tx_utils, ) +from opentrons.protocol_engine import errors if TYPE_CHECKING: from .well import WellCore @@ -125,6 +126,7 @@ def __init__( target_well: Optional[WellCore], tip_state: TipState, transfer_type: TransferType, + target_end_location: Optional[Location] = None, ) -> None: """Create a TransferComponentsExecutor instance. @@ -150,6 +152,7 @@ def __init__( self._instrument = instrument_core self._transfer_properties = transfer_properties self._target_location = target_location + self._target_end_location = target_end_location self._target_well = target_well self._tip_state: TipState = deepcopy(tip_state) # don't modify caller's object self._transfer_type: TransferType = transfer_type @@ -232,13 +235,15 @@ def aspirate_and_wait(self, volume: float) -> None: correction_volume = aspirate_props.correction_by_volume.get_for_volume( self._instrument.get_current_volume() + volume ) + print(f"Should be in place {self._target_end_location is None}") self._instrument.aspirate( location=self._target_location, - well_core=None, + end_location=self._target_end_location, + well_core=None if self._target_end_location is None else self._target_well, volume=volume, rate=1, flow_rate=aspirate_props.flow_rate_by_volume.get_for_volume(volume), - in_place=True, + in_place=self._target_end_location is None, correction_volume=correction_volume, ) self._tip_state.append_liquid(volume) @@ -262,11 +267,12 @@ def dispense_and_wait( ) self._instrument.dispense( location=self._target_location, - well_core=None, + end_location=self._target_end_location, + well_core=None if self._target_end_location is None else self._target_well, volume=volume, rate=1, flow_rate=dispense_properties.flow_rate_by_volume.get_for_volume(volume), - in_place=True, + in_place=self._target_end_location is None, push_out=push_out_override, correction_volume=correction_volume, ) @@ -973,6 +979,8 @@ def absolute_point_from_position_reference_and_offset( position_reference: PositionReference, offset: Coordinate, mount: Mount, + fallback_position_reference: Optional[PositionReference] = None, + fallback_offset: Optional[Coordinate] = None, ) -> Point: """Return the absolute point, given the well, the position reference and offset. @@ -982,25 +990,57 @@ def absolute_point_from_position_reference_and_offset( So, for liquid height estimation after an aspirate, well_volume_difference is expected to be a -ve value while for a dispense, it will be a +ve value. """ - match position_reference: - case PositionReference.WELL_TOP: - reference_point = well.get_top(0) - case PositionReference.WELL_BOTTOM: - reference_point = well.get_bottom(0) - case PositionReference.WELL_CENTER: - reference_point = well.get_center() - case PositionReference.LIQUID_MENISCUS: - estimated_liquid_height = well.estimate_liquid_height_after_pipetting( - mount=mount, - operation_volume=well_volume_difference, - ) - if isinstance(estimated_liquid_height, (float, int)): - reference_point = well.get_bottom(z_offset=estimated_liquid_height) - else: - # If estimated liquid height gives a SimulatedProbeResult then - # assume meniscus is at well center. - # Will this cause more harm than good? Is there a better alternative to this? + try: + match position_reference: + case PositionReference.WELL_TOP: + reference_point = well.get_top(0) + case PositionReference.WELL_BOTTOM: + reference_point = well.get_bottom(0) + case PositionReference.WELL_CENTER: reference_point = well.get_center() - case _: - raise ValueError(f"Unknown position reference {position_reference}") - return reference_point + Point(offset.x, offset.y, offset.z) + case PositionReference.LIQUID_MENISCUS_START: + estimated_liquid_height = well.estimate_liquid_height_after_pipetting( + mount=mount, + operation_volume=0.0, + ) + if isinstance(estimated_liquid_height, (float, int)): + reference_point = well.get_bottom(z_offset=estimated_liquid_height) + else: + # If estimated liquid height gives a SimulatedProbeResult then + # assume meniscus is at well center. + # Will this cause more harm than good? Is there a better alternative to this? + reference_point = well.get_center() + case PositionReference.LIQUID_MENISCUS_END | PositionReference.LIQUID_MENISCUS: + estimated_liquid_height = well.estimate_liquid_height_after_pipetting( + mount=mount, + operation_volume=well_volume_difference, + ) + if isinstance(estimated_liquid_height, (float, int)): + reference_point = well.get_bottom(z_offset=estimated_liquid_height) + else: + # If estimated liquid height gives a SimulatedProbeResult then + # assume meniscus is at well center. + # Will this cause more harm than good? Is there a better alternative to this? + reference_point = well.get_center() + case _: + raise ValueError(f"Unknown position reference {position_reference}") + return reference_point + Point(offset.x, offset.y, offset.z) + except ( + errors.LiquidHeightUnknownError, + errors.IncompleteWellDefinitionError, + errors.IncompleteLabwareDefinitionError, + ) as e: + # We don't have enough data to use the main location so use the fallback ones. + if fallback_position_reference is None or fallback_offset is None: + # No fallback so raise the error + raise e + # try again with the fallback and set the fallbacks to None so we don't recurse forever + return absolute_point_from_position_reference_and_offset( + well, + well_volume_difference, + fallback_position_reference, + fallback_offset, + mount, + None, + None, + ) diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index b1d50c21f0c..dcacb23039b 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -18,7 +18,11 @@ from opentrons_shared_data.liquid_classes.liquid_class_definition import ( TransferProperties as SharedTransferProperties, ) -from opentrons_shared_data.liquid_classes import DEFAULT_LC_VERSION, definition_exists +from opentrons_shared_data.liquid_classes import ( + DEFAULT_LC_VERSION, + definition_exists, + DEFAULT_SCHEMA_VERSION, +) from opentrons_shared_data.liquid_classes.types import TransferPropertiesDict from opentrons_shared_data.pipette.types import PipetteNameType @@ -1530,7 +1534,7 @@ def define_liquid_class( :returns: A new LiquidClass object. """ - if definition_exists(name, DEFAULT_LC_VERSION): + if definition_exists(name, DEFAULT_LC_VERSION, DEFAULT_SCHEMA_VERSION): raise ValueError( f"Liquid class named {name} already exists. Please specify a different name." ) diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index da65eefe1ec..b82c93410ff 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -876,7 +876,7 @@ def minimal_liquid_class_def2() -> LiquidClassSchemaV1: singleDispense=SingleDispenseProperties( submerge=Submerge( startPosition=TipPosition( - positionReference=PositionReference.LIQUID_MENISCUS, + positionReference=PositionReference.LIQUID_MENISCUS_END, offset=Coordinate(x=0, y=0, z=-5), ), speed=100, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 33619d2f329..ad24afefe65 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -2138,6 +2138,8 @@ def test_aspirate_liquid_class_for_transfer_without_volume_config( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2148,6 +2150,7 @@ def test_aspirate_liquid_class_for_transfer_without_volume_config( target_well=source_well, tip_state=TipState(), transfer_type=TransferType.ONE_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2239,6 +2242,8 @@ def test_aspirate_liquid_class_using_volume_config_below_2_28( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2249,6 +2254,7 @@ def test_aspirate_liquid_class_using_volume_config_below_2_28( target_well=source_well, tip_state=TipState(), # air gap would have been removed during volume config transfer_type=TransferType.ONE_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2368,6 +2374,8 @@ def test_aspirate_liquid_class_using_volume_config_2_28_and_above( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2378,6 +2386,7 @@ def test_aspirate_liquid_class_using_volume_config_2_28_and_above( target_well=source_well, tip_state=TipState(), # air gap would have been removed during volume config transfer_type=TransferType.ONE_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2500,6 +2509,8 @@ def test_aspirate_liquid_class_2_28_and_above_skips_configure_volume( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2512,6 +2523,7 @@ def test_aspirate_liquid_class_2_28_and_above_skips_configure_volume( last_liquid_and_air_gap_in_tip=last_liquid_and_airgap_in_tip ), transfer_type=TransferType.ONE_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2564,6 +2576,8 @@ def test_aspirate_liquid_class_for_consolidate( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2574,6 +2588,7 @@ def test_aspirate_liquid_class_for_consolidate( target_well=source_well, tip_state=TipState(), transfer_type=TransferType.MANY_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2815,6 +2830,8 @@ def test_dispense_liquid_class( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=0, y=0, z=-5), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2825,6 +2842,7 @@ def test_dispense_liquid_class( target_well=dest_well, tip_state=TipState(), transfer_type=TransferType.ONE_TO_ONE, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2895,6 +2913,8 @@ def test_dispense_liquid_class_during_multi_dispense( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=1, y=3, z=2), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=1, y=3, z=2), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2905,6 +2925,7 @@ def test_dispense_liquid_class_during_multi_dispense( target_well=dest_well, tip_state=TipState(), transfer_type=TransferType.ONE_TO_MANY, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2977,6 +2998,8 @@ def test_last_dispense_liquid_class_during_multi_dispense( position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=1, y=3, z=2), mount=Mount.LEFT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=1, y=3, z=2), ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2987,6 +3010,7 @@ def test_last_dispense_liquid_class_during_multi_dispense( target_well=dest_well, tip_state=TipState(), transfer_type=TransferType.ONE_TO_MANY, + target_end_location=None, ) ).then_return(mock_transfer_components_executor) decoy.when( diff --git a/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py b/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py index 1668fd2e231..29381e07213 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py @@ -32,6 +32,7 @@ LocationCheckDescriptors, ) from opentrons.types import Location, Point, Mount +from opentrons.protocol_engine import errors @pytest.fixture @@ -327,7 +328,8 @@ def test_submerge_raises_when_submerge_point_is_invalid( argnames=["position_reference"], argvalues=[ [PositionReference.WELL_BOTTOM], - [PositionReference.LIQUID_MENISCUS], + [PositionReference.LIQUID_MENISCUS_START], + [PositionReference.LIQUID_MENISCUS_END], ], ) def test_aspirate_and_wait( @@ -362,6 +364,7 @@ def test_aspirate_and_wait( location=Location(Point(1, 2, 3), labware=None), well_core=None, volume=10, + end_location=None, rate=1, flow_rate=aspirate_flow_rate, in_place=True, @@ -400,7 +403,8 @@ def test_aspirate_and_wait_skips_delay( argnames=["position_reference"], argvalues=[ [PositionReference.WELL_BOTTOM], - [PositionReference.LIQUID_MENISCUS], + [PositionReference.LIQUID_MENISCUS_START], + [PositionReference.LIQUID_MENISCUS_END], ], ) def test_dispense_and_wait( @@ -441,6 +445,7 @@ def test_dispense_and_wait( volume=10, rate=1, flow_rate=dispense_flow_rate, + end_location=None, in_place=True, push_out=123, correction_volume=correction_volume, @@ -538,6 +543,7 @@ def test_dispense_into_trash_and_wait( volume=10, rate=1, flow_rate=dispense_flow_rate, + end_location=None, in_place=True, push_out=123, correction_volume=correction_volume, @@ -586,6 +592,7 @@ def test_mix( volume=50, rate=1, flow_rate=aspirate_flow_rate, + end_location=None, in_place=True, correction_volume=aspirate_correction_volume, ), @@ -596,6 +603,7 @@ def test_mix( volume=50, rate=1, flow_rate=dispense_flow_rate, + end_location=None, in_place=True, push_out=2.0, correction_volume=dispense_correction_volume, @@ -637,6 +645,7 @@ def test_mix_disabled( volume=50, rate=1, flow_rate=aspirate_flow_rate, + end_location=None, in_place=True, correction_volume=correction_volume, ), @@ -681,6 +690,7 @@ def test_pre_wet( volume=40, rate=1, flow_rate=aspirate_flow_rate, + end_location=None, in_place=True, correction_volume=aspirate_correction_volume, ), @@ -691,6 +701,7 @@ def test_pre_wet( volume=40, rate=1, flow_rate=dispense_flow_rate, + end_location=None, in_place=True, push_out=0, correction_volume=dispense_correction_volume, @@ -730,6 +741,7 @@ def test_pre_wet_disabled( volume=40, rate=1, flow_rate=aspirate_flow_rate, + end_location=None, in_place=True, correction_volume=aspirate_correction_volume, ), @@ -2431,7 +2443,12 @@ def test_multi_dispense_retract_raises_for_invalid_retract_point( Point(38, 40, 42), ), ( - PositionReference.LIQUID_MENISCUS, + PositionReference.LIQUID_MENISCUS_END, + Coordinate(x=41, y=42, z=43), + Point(45, 47, 64), + ), + ( + PositionReference.LIQUID_MENISCUS_START, Coordinate(x=41, y=42, z=43), Point(45, 47, 61), ), @@ -2450,15 +2467,24 @@ def test_absolute_point_from_position_reference_and_offset( well_bottom_point = Point(4, 5, 6) well_center_point = Point(7, 8, 9) estimated_liquid_height = 12 + operation_height_change = 3 decoy.when(well.get_bottom(0)).then_return(well_bottom_point) decoy.when(well.get_top(0)).then_return(well_top_point) decoy.when(well.get_center()).then_return(well_center_point) + # after the change decoy.when( well.estimate_liquid_height_after_pipetting( operation_volume=123, mount=Mount.RIGHT ), + ).then_return(estimated_liquid_height + operation_height_change) + # before the change + decoy.when( + well.estimate_liquid_height_after_pipetting( + operation_volume=0, mount=Mount.RIGHT + ), ).then_return(estimated_liquid_height) decoy.when(well.get_bottom(12)).then_return(Point(4, 5, 18)) + decoy.when(well.get_bottom(15)).then_return(Point(4, 5, 21)) assert ( absolute_point_from_position_reference_and_offset( @@ -2472,6 +2498,63 @@ def test_absolute_point_from_position_reference_and_offset( ) +@pytest.mark.parametrize( + argnames=["error"], + argvalues=[ + [errors.LiquidHeightUnknownError("LiquidHeightUnknownError")], + [errors.IncompleteWellDefinitionError("IncompleteWellDefinitionError")], + [errors.IncompleteLabwareDefinitionError("IncompleteLabwareDefinitionError")], + ], +) +def test_absolute_point_from_position_reference_and_offset_with_fallback( + decoy: Decoy, + error: errors.ProtocolEngineError, +) -> None: + """It should return the correct absolute point based on well, position reference and offset.""" + well = decoy.mock(cls=WellCore) + + well_bottom_point = Point(4, 5, 6) + decoy.when(well.get_bottom(0)).then_return(well_bottom_point) + + decoy.when( + well.estimate_liquid_height_after_pipetting( + operation_volume=123, mount=Mount.RIGHT + ), + ).then_raise(error) + + assert absolute_point_from_position_reference_and_offset( + well=well, + well_volume_difference=123, + position_reference=PositionReference.LIQUID_MENISCUS_END, + offset=Coordinate(x=0, y=0, z=-2), + mount=Mount.RIGHT, + fallback_position_reference=PositionReference.WELL_BOTTOM, + fallback_offset=Coordinate(x=21, y=22, z=23), + ) == Point(25, 27, 29) + + +def test_absolute_point_from_position_reference_and_offset_without_fallback( + decoy: Decoy, +) -> None: + """It should return the correct absolute point based on well, position reference and offset.""" + well = decoy.mock(cls=WellCore) + + decoy.when( + well.estimate_liquid_height_after_pipetting( + operation_volume=123, mount=Mount.RIGHT + ), + ).then_raise(errors.LiquidHeightUnknownError("LiquidHeightUnknownError")) + + with pytest.raises(errors.LiquidHeightUnknownError): + absolute_point_from_position_reference_and_offset( + well=well, + well_volume_difference=123, + position_reference=PositionReference.LIQUID_MENISCUS_END, + offset=Coordinate(x=0, y=0, z=-2), + mount=Mount.RIGHT, + ) + + def test_absolute_point_from_position_reference_and_offset_raises_errors( decoy: Decoy, ) -> None: diff --git a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py index 23d5e5355c6..72e614eead4 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py @@ -24,7 +24,7 @@ def test_build_aspirate_settings() -> None: assert ( aspirate_properties.submerge.start_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) assert aspirate_properties.submerge.start_position.offset == Coordinate( x=0, y=0, z=-5 @@ -113,10 +113,10 @@ def test_aspirate_settings_overrides() -> None: aspirate_properties.retract.delay.duration = 0.5 assert aspirate_properties.retract.delay.duration == 0.5 - aspirate_properties.aspirate_position.position_reference = "liquid-meniscus" # type: ignore[assignment] + aspirate_properties.aspirate_position.position_reference = "liquid-meniscus-end" # type: ignore[assignment] assert ( aspirate_properties.aspirate_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) aspirate_properties.aspirate_position.offset = -1, -2, -3 # type: ignore[assignment] assert aspirate_properties.aspirate_position.offset == Coordinate(x=-1, y=-2, z=-3) @@ -133,6 +133,22 @@ def test_aspirate_settings_overrides() -> None: aspirate_properties.delay.duration = 2.3 assert aspirate_properties.delay.duration == 2.3 + aspirate_properties.override_tip_positions( + new_position="well-bottom", + new_offset=Coordinate(x=4, y=5, z=6), + ) + assert ( + aspirate_properties.fallback_aspirate_position.position_reference.value + == "liquid-meniscus-end" + ) + assert aspirate_properties.fallback_aspirate_position.offset == Coordinate( + x=-1, y=-2, z=-3 + ) + assert ( + aspirate_properties.aspirate_position.position_reference.value == "well-bottom" + ) + assert aspirate_properties.aspirate_position.offset == Coordinate(x=4, y=5, z=6) + def test_build_single_dispense_settings() -> None: """It should convert the shared data single dispense settings to the PAPI type.""" @@ -144,7 +160,7 @@ def test_build_single_dispense_settings() -> None: assert ( single_dispense_properties.submerge.start_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) assert single_dispense_properties.submerge.start_position.offset == Coordinate( x=0, y=0, z=-5 @@ -258,10 +274,10 @@ def test_single_dispense_settings_override() -> None: single_dispense_properties.retract.delay.duration = 0.1 assert single_dispense_properties.retract.delay.duration == 0.1 - single_dispense_properties.dispense_position.position_reference = "liquid-meniscus" # type: ignore[assignment] + single_dispense_properties.dispense_position.position_reference = "liquid-meniscus-end" # type: ignore[assignment] assert ( single_dispense_properties.dispense_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) single_dispense_properties.dispense_position.offset = 11, 22, -33 # type: ignore[assignment] assert single_dispense_properties.dispense_position.offset == Coordinate( @@ -278,6 +294,25 @@ def test_single_dispense_settings_override() -> None: single_dispense_properties.delay.duration = 25.25 assert single_dispense_properties.delay.duration == 25.25 + single_dispense_properties.override_tip_positions( + new_position="well-bottom", + new_offset=Coordinate(x=4, y=5, z=6), + ) + assert ( + single_dispense_properties.fallback_dispense_position.position_reference.value + == "liquid-meniscus-end" + ) + assert single_dispense_properties.fallback_dispense_position.offset == Coordinate( + x=11, y=22, z=-33 + ) + assert ( + single_dispense_properties.dispense_position.position_reference.value + == "well-bottom" + ) + assert single_dispense_properties.dispense_position.offset == Coordinate( + x=4, y=5, z=6 + ) + def test_build_multi_dispense_settings() -> None: """It should convert the shared data multi dispense settings to the PAPI type.""" @@ -291,7 +326,7 @@ def test_build_multi_dispense_settings() -> None: assert ( multi_dispense_properties.submerge.start_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) assert multi_dispense_properties.submerge.start_position.offset == Coordinate( x=0, y=0, z=-5 @@ -321,7 +356,6 @@ def test_build_multi_dispense_settings() -> None: assert multi_dispense_properties.retract.blowout.flow_rate is None assert multi_dispense_properties.retract.delay.enabled is True assert multi_dispense_properties.retract.delay.duration == 1 - assert ( multi_dispense_properties.dispense_position.position_reference.value == "well-bottom" @@ -404,10 +438,10 @@ def test_multi_dispense_settings_override() -> None: multi_dispense_properties.retract.delay.duration = 0.1 assert multi_dispense_properties.retract.delay.duration == 0.1 - multi_dispense_properties.dispense_position.position_reference = "liquid-meniscus" # type: ignore[assignment] + multi_dispense_properties.dispense_position.position_reference = "liquid-meniscus-end" # type: ignore[assignment] assert ( multi_dispense_properties.dispense_position.position_reference.value - == "liquid-meniscus" + == "liquid-meniscus-end" ) multi_dispense_properties.dispense_position.offset = 11, 22, -33 # type: ignore[assignment] assert multi_dispense_properties.dispense_position.offset == Coordinate( @@ -418,6 +452,25 @@ def test_multi_dispense_settings_override() -> None: multi_dispense_properties.delay.duration = 25.25 assert multi_dispense_properties.delay.duration == 25.25 + multi_dispense_properties.override_tip_positions( + new_position="well-bottom", + new_offset=Coordinate(x=4, y=5, z=6), + ) + assert ( + multi_dispense_properties.fallback_dispense_position.position_reference.value + == "liquid-meniscus-end" + ) + assert multi_dispense_properties.fallback_dispense_position.offset == Coordinate( + x=11, y=22, z=-33 + ) + assert ( + multi_dispense_properties.dispense_position.position_reference.value + == "well-bottom" + ) + assert multi_dispense_properties.dispense_position.offset == Coordinate( + x=4, y=5, z=6 + ) + def test_build_multi_dispense_settings_none( minimal_liquid_class_def2: LiquidClassSchemaV1, diff --git a/hardware-testing/hardware_testing/gravimetric/protocol_replacement/gravimetric.py b/hardware-testing/hardware_testing/gravimetric/protocol_replacement/gravimetric.py index 192f150949f..9739ac9cb19 100644 --- a/hardware-testing/hardware_testing/gravimetric/protocol_replacement/gravimetric.py +++ b/hardware-testing/hardware_testing/gravimetric/protocol_replacement/gravimetric.py @@ -1021,13 +1021,18 @@ def run_blank_test( fixture_settings.pipette.name, tip_rack=tiprack_uri ) offset = _get_offset_for_channel(fixture_settings, channel, 10) - transfer_properties.aspirate.aspirate_position.offset = offset - transfer_properties.dispense.dispense_position.offset = offset - transfer_properties.aspirate.aspirate_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + + transfer_properties.aspirate.override_tip_positions( + new_position=PositionReference.LIQUID_MENISCUS_START, + new_offset=offset, + new_end_position=PositionReference.LIQUID_MENISCUS_END, + new_end_offset=offset, ) - transfer_properties.dispense.dispense_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + transfer_properties.dispense.override_tip_positions( + new_position=PositionReference.LIQUID_MENISCUS_START, + new_offset=offset, + new_end_position=PositionReference.LIQUID_MENISCUS_END, + new_end_offset=offset, ) fixture_settings.pipette._core.load_liquid_class( # type: ignore [attr-defined] @@ -1113,32 +1118,35 @@ def run_one_test( # aspirate and dispense submerge start offsets. transfer_properties.aspirate.submerge.start_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_START ) transfer_properties.dispense.submerge.start_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_START ) transfer_properties.aspirate.submerge.start_position.offset = retracted_offset transfer_properties.dispense.submerge.start_position.offset = retracted_offset # aspirate and dispense offsets - transfer_properties.aspirate.aspirate_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + transfer_properties.aspirate.override_tip_positions( + new_position=PositionReference.LIQUID_MENISCUS_START, + new_offset=submerged_offset, + new_end_position=PositionReference.LIQUID_MENISCUS_END, + new_end_offset=submerged_offset, ) - transfer_properties.dispense.dispense_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + transfer_properties.dispense.override_tip_positions( + new_position=PositionReference.LIQUID_MENISCUS_START, + new_offset=submerged_offset, + new_end_position=PositionReference.LIQUID_MENISCUS_END, + new_end_offset=submerged_offset, ) - transfer_properties.aspirate.aspirate_position.offset = submerged_offset - transfer_properties.dispense.dispense_position.offset = submerged_offset - # aspirate and dispense retract end offsets transfer_properties.aspirate.retract.end_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_END ) transfer_properties.dispense.retract.end_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_END ) transfer_properties.aspirate.retract.end_position.offset = retracted_offset transfer_properties.dispense.retract.end_position.offset = retracted_offset diff --git a/hardware-testing/hardware_testing/protocols/universal_photometric.py b/hardware-testing/hardware_testing/protocols/universal_photometric.py index 72ab0a25f9a..daef40f6b27 100644 --- a/hardware-testing/hardware_testing/protocols/universal_photometric.py +++ b/hardware-testing/hardware_testing/protocols/universal_photometric.py @@ -414,13 +414,13 @@ def _get_transfer_settings(tiprack: Labware, first_trial: bool) -> LiquidClass: transfer_properties.aspirate.aspirate_position.offset = asp_offset transfer_properties.aspirate.retract.end_position.offset = asp_offset transfer_properties.aspirate.aspirate_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_END ) transfer_properties.dispense.submerge.start_position.offset = disp_offset transfer_properties.dispense.dispense_position.offset = disp_offset transfer_properties.dispense.retract.end_position.offset = disp_offset transfer_properties.dispense.dispense_position.position_reference = ( - PositionReference.LIQUID_MENISCUS + PositionReference.LIQUID_MENISCUS_END ) if not ctx.params.use_pip_motion_defaults: # type: ignore [attr-defined] diff --git a/shared-data/command/schemas/16.json b/shared-data/command/schemas/16.json index b62cf693da8..7d68164e03c 100644 --- a/shared-data/command/schemas/16.json +++ b/shared-data/command/schemas/16.json @@ -259,6 +259,11 @@ "additionalProperties": false, "description": "Properties specific to the aspirate function.", "properties": { + "aspirateEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic aspirate.", + "title": "Aspirateendposition" + }, "aspiratePosition": { "$ref": "#/$defs/TipPosition", "description": "Tip position during aspirate." @@ -341,6 +346,16 @@ "$ref": "#/$defs/MixProperties", "description": "Mixing settings for before an aspirate" }, + "overrideAspirateEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic aspirate if this position is not possible it will fall back to aspirate_end_position.", + "title": "Overrideaspirateendposition" + }, + "overrideAspiratePosition": { + "$ref": "#/$defs/TipPosition", + "description": "Tip position during aspirate if this position is not possible it will fall back to aspirate_position.", + "title": "Overrideaspirateposition" + }, "preWet": { "description": "Whether to perform a pre-wet action.", "title": "Prewet", @@ -3944,6 +3959,11 @@ "$ref": "#/$defs/DelayProperties", "description": "Delay settings after each dispense" }, + "dispenseEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic dispense.", + "title": "Dispenseendposition" + }, "dispensePosition": { "$ref": "#/$defs/TipPosition", "description": "Tip position during dispense." @@ -4020,6 +4040,16 @@ "title": "Flowratebyvolume", "type": "array" }, + "overrideDispenseEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic dispense if this position is not possible it will fall back to dispense_end_position.", + "title": "Overridedispenseendposition" + }, + "overrideDispensePosition": { + "$ref": "#/$defs/TipPosition", + "description": "Tip position during dispense if this position is not possible it will fall back to dispense_position.", + "title": "Overridedispenseposition" + }, "retract": { "$ref": "#/$defs/RetractDispense", "description": "Pipette retract settings after a multi-dispense." @@ -4230,7 +4260,14 @@ }, "PositionReference": { "description": "Positional reference for liquid handling operations.", - "enum": ["well-bottom", "well-top", "well-center", "liquid-meniscus"], + "enum": [ + "well-bottom", + "well-top", + "well-center", + "liquid-meniscus-start", + "liquid-meniscus-end", + "liquid-meniscus" + ], "title": "PositionReference", "type": "string" }, @@ -5534,6 +5571,11 @@ "$ref": "#/$defs/DelayProperties", "description": "Delay after dispense, in seconds." }, + "dispenseEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic dispense.", + "title": "Dispenseendposition" + }, "dispensePosition": { "$ref": "#/$defs/TipPosition", "description": "Tip position during dispense." @@ -5578,6 +5620,16 @@ "$ref": "#/$defs/MixProperties", "description": "Mixing settings for after a dispense" }, + "overrideDispenseEndPosition": { + "$ref": "#/$defs/TipPosition", + "description": "Ending tip position during dynamic dispense if this position is not possible it will fall back to dispense_end_position.", + "title": "Overridedispenseendposition" + }, + "overrideDispensePosition": { + "$ref": "#/$defs/TipPosition", + "description": "Tip position during dispense if this position is not possible it will fall back to dispense_position.", + "title": "Overridedispenseposition" + }, "pushOutByVolume": { "description": "Settings for pushout keyed by target dispense volume.", "items": { diff --git a/shared-data/liquid-class/fixtures/1/fixture_glycerol50.json b/shared-data/liquid-class/fixtures/1/fixture_glycerol50.json index 0f6c7ff7b0e..9b9cf17ea7f 100644 --- a/shared-data/liquid-class/fixtures/1/fixture_glycerol50.json +++ b/shared-data/liquid-class/fixtures/1/fixture_glycerol50.json @@ -14,7 +14,7 @@ "aspirate": { "submerge": { "startPosition": { - "positionReference": "liquid-meniscus", + "positionReference": "liquid-meniscus-end", "offset": { "x": 0, "y": 0, @@ -89,7 +89,7 @@ "singleDispense": { "submerge": { "startPosition": { - "positionReference": "liquid-meniscus", + "positionReference": "liquid-meniscus-end", "offset": { "x": 0, "y": 0, @@ -177,7 +177,7 @@ "multiDispense": { "submerge": { "startPosition": { - "positionReference": "liquid-meniscus", + "positionReference": "liquid-meniscus-end", "offset": { "x": 0, "y": 0, diff --git a/shared-data/liquid-class/fixtures/2/fixture_glycerol50.json b/shared-data/liquid-class/fixtures/2/fixture_glycerol50.json new file mode 100644 index 00000000000..d6560482735 --- /dev/null +++ b/shared-data/liquid-class/fixtures/2/fixture_glycerol50.json @@ -0,0 +1,296 @@ +{ + "liquidClassName": "fixture_glycerol50", + "displayName": "Viscous", + "description": "50% glycerol", + "schemaVersion": 2, + "version": 2, + "namespace": "opentrons", + "byPipette": [ + { + "pipetteModel": "p20_single_gen2", + "byTipType": [ + { + "tiprack": "opentrons_96_tiprack_20ul", + "aspirate": { + "submerge": { + "startPosition": { + "positionReference": "liquid-meniscus-end", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "endPosition": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + } + }, + "speed": 100, + "airGapByVolume": [ + [5.0, 3.0], + [10.0, 4.0] + ], + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmFromEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "aspiratePosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "aspirateEndPosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -6 + } + }, + "overrideAspiratePosition": { + "positionReference": "liquid-meniscus-start", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "overrideAspirateEndPosition": { + "positionReference": "liquid-meniscus-end", + "offset": { + "x": 0, + "y": 0, + "z": -6 + } + }, + "flowRateByVolume": [[10.0, 50.0]], + "correctionByVolume": [ + [1.0, -2.5], + [10.0, 3.0] + ], + "preWet": true, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 2 + } + } + }, + "singleDispense": { + "submerge": { + "startPosition": { + "positionReference": "liquid-meniscus-end", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "endPosition": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + } + }, + "speed": 100, + "airGapByVolume": [ + [5.0, 3.0], + [10.0, 4.0] + ], + "blowout": { + "enable": true, + "params": { + "location": "trash", + "flowRate": 100 + } + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmFromEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "dispensePosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "dispenseEndPosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -6 + } + }, + "flowRateByVolume": [ + [10.0, 40.0], + [20.0, 30.0] + ], + "correctionByVolume": [ + [2.0, -1.5], + [20.0, 2.0] + ], + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "pushOutByVolume": [ + [10.0, 7.0], + [20.0, 10.0] + ], + "delay": { + "enable": true, + "params": { + "duration": 2.5 + } + } + }, + "multiDispense": { + "submerge": { + "startPosition": { + "positionReference": "liquid-meniscus-end", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "endPosition": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + } + }, + "speed": 100, + "airGapByVolume": [ + [5.0, 3.0], + [10.0, 4.0] + ], + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmFromEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + }, + "blowout": { + "enable": false + } + }, + "dispensePosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "overrideDispensePosition": { + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -10 + } + }, + "flowRateByVolume": [ + [10.0, 40.0], + [20.0, 30.0] + ], + "correctionByVolume": [ + [3.0, -0.5], + [30.0, 1.0] + ], + "conditioningByVolume": [[5.0, 5.0]], + "disposalByVolume": [[5.0, 3.0]], + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + } + } + ] + } + ] +} diff --git a/shared-data/liquid-class/schemas/1.json b/shared-data/liquid-class/schemas/1.json index e5ffbee19fa..f9167427cdf 100644 --- a/shared-data/liquid-class/schemas/1.json +++ b/shared-data/liquid-class/schemas/1.json @@ -39,7 +39,13 @@ "positionReference": { "type": "string", "description": "Reference point for positioning.", - "enum": ["well-bottom", "well-top", "well-center", "liquid-meniscus"] + "enum": [ + "well-bottom", + "well-top", + "well-center", + "liquid-meniscus-start", + "liquid-meniscus-end" + ] }, "coordinate": { "type": "object", diff --git a/shared-data/liquid-class/schemas/2.json b/shared-data/liquid-class/schemas/2.json new file mode 100644 index 00000000000..c92c7ab79a2 --- /dev/null +++ b/shared-data/liquid-class/schemas/2.json @@ -0,0 +1,538 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "opentronsLiquidClassSchemaV2", + "title": "Liquid Class Schema", + "description": "Schema for defining a single liquid class's properties for liquid handling functions.", + "type": "object", + "definitions": { + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "safeString": { + "description": "A string safe to use for namespace. Lowercase-only.", + "type": "string", + "pattern": "^[a-z0-9._]+$" + }, + "delay": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether delay is enabled." + }, + "params": { + "type": "object", + "properties": { + "duration": { + "#ref": "#/definitions/positiveNumber", + "description": "Duration of delay, in seconds." + } + }, + "required": ["duration"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "positionReference": { + "type": "string", + "description": "Reference point for positioning.", + "enum": [ + "well-bottom", + "well-top", + "well-center", + "liquid-meniscus-start", + "liquid-meniscus-end" + ] + }, + "coordinate": { + "type": "object", + "description": "3-dimensional coordinate.", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "tipPosition": { + "type": "object", + "description": "Positional reference and relative offset for where a tip should go.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + } + }, + "required": ["positionReference", "offset"], + "additionalProperties": false + }, + "touchTip": { + "type": "object", + "description": "Shared properties for the touch-tip function.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether touch-tip is enabled." + }, + "params": { + "type": "object", + "properties": { + "zOffset": { + "type": "number", + "description": "Offset from the top of the well for touch-tip, in millimeters." + }, + "mmFromEdge": { + "type": "number", + "description": "Offset away from the the well edge, in millimeters." + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Touch-tip speed, in millimeters per second." + } + }, + "required": ["zOffset", "mmFromEdge", "speed"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "airGapByVolume": { + "type": "array", + "description": "Settings for air gap keyed by current volume in the tip.", + "items": { + "type": "array", + "items": { "$ref": "#/definitions/positiveNumber" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "flowRateByVolume": { + "type": "array", + "description": "Settings for flow rate keyed by target aspiration/dispense volume.", + "items": { + "type": "array", + "items": { "$ref": "#/definitions/positiveNumber" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "pushOutByVolume": { + "type": "array", + "description": "Settings for pushout keyed by target dispense volume.", + "items": { + "type": "array", + "items": { "$ref": "#/definitions/positiveNumber" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "disposalByVolume": { + "type": "array", + "description": "Settings for disposal volume, keyed by the total volume aspirated for a multi-dispense.", + "items": { + "type": "array", + "items": { "$ref": "#/definitions/positiveNumber" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "conditioningByVolume": { + "type": "array", + "description": "Settings for conditioning volume keyed by the total volume aspirated for a multi-dispense.", + "items": { + "type": "array", + "items": { "$ref": "#/definitions/positiveNumber" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "correctionByVolume": { + "type": "array", + "description": "Settings for volume correction, keyed by the total volume in tip after the aspirate/dispense action. The correction value provides additional information about how much the pipette plunger should move to accurately pipette the specified volume when using this liquid class.", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 1 + }, + "mix": { + "type": "object", + "description": "Mixing properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether mix is enabled." + }, + "params": { + "type": "object", + "properties": { + "repetitions": { + "type": "integer", + "description": "Number of mixing repetitions.", + "minimum": 0 + }, + "volume": { + "$ref": "#/definitions/positiveNumber", + "description": "Volume used for mixing, in microliters." + } + }, + "required": ["repetitions", "volume"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "blowout": { + "type": "object", + "description": "Blowout properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether blow-out is enabled." + }, + "params": { + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": ["source", "destination", "trash"], + "description": "Location for blow out." + }, + "flowRate": { + "$ref": "#/definitions/positiveNumber", + "description": "Flow rate for blow out, in microliters per second." + } + }, + "required": ["location", "flowRate"] + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "submerge": { + "type": "object", + "description": "Shared properties for the submerge function before aspiration or dispense.", + "properties": { + "startPosition": { + "$ref": "#/definitions/tipPosition" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of submerging, in millimeters per second." + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": ["startPosition", "speed", "delay"], + "additionalProperties": false + }, + "retractAspirate": { + "type": "object", + "description": "Shared properties for the retract function after an aspiration.", + "properties": { + "endPosition": { + "$ref": "#/definitions/tipPosition" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": ["endPosition", "speed", "airGapByVolume", "delay"], + "additionalProperties": false + }, + "retractDispense": { + "type": "object", + "description": "Shared properties for the retract function after a dispense.", + "properties": { + "endPosition": { + "$ref": "#/definitions/tipPosition" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "blowout": { + "$ref": "#/definitions/blowout" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "endPosition", + "speed", + "airGapByVolume", + "blowout", + "touchTip", + "delay" + ], + "additionalProperties": false + }, + "aspirateParams": { + "type": "object", + "description": "Parameters specific to the aspirate function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractAspirate" + }, + "aspiratePosition": { + "$ref": "#/definitions/tipPosition" + }, + "aspirateEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideAspiratePosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideAspirateEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "correctionByVolume": { + "$ref": "#/definitions/correctionByVolume" + }, + "preWet": { + "type": "boolean", + "description": "Whether to perform a pre-wet action." + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "aspiratePosition", + "flowRateByVolume", + "correctionByVolume", + "preWet", + "mix", + "delay" + ], + "additionalProperties": false + }, + "singleDispenseParams": { + "type": "object", + "description": "Parameters specific to the single-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "dispensePosition": { + "$ref": "#/definitions/tipPosition" + }, + "dispenseEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideDispensePosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideDispenseEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "correctionByVolume": { + "$ref": "#/definitions/correctionByVolume" + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "pushOutByVolume": { + "$ref": "#/definitions/pushOutByVolume" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "dispensePosition", + "flowRateByVolume", + "correctionByVolume", + "mix", + "pushOutByVolume", + "delay" + ], + "additionalProperties": false + }, + "multiDispenseParams": { + "type": "object", + "description": "Parameters specific to the multi-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "dispensePosition": { + "$ref": "#/definitions/tipPosition" + }, + "dispenseEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideDispensePosition": { + "$ref": "#/definitions/tipPosition" + }, + "overrideDispenseEndPosition": { + "$ref": "#/definitions/tipPosition" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "correctionByVolume": { + "$ref": "#/definitions/correctionByVolume" + }, + "conditioningByVolume": { + "$ref": "#/definitions/conditioningByVolume" + }, + "disposalByVolume": { + "$ref": "#/definitions/disposalByVolume" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "dispensePosition", + "flowRateByVolume", + "correctionByVolume", + "conditioningByVolume", + "disposalByVolume", + "delay" + ], + "additionalProperties": false + } + }, + "properties": { + "liquidClassName": { + "$ref": "#/definitions/safeString", + "description": "The name of the liquid class specified when loading into protocol (e.g., water, ethanol, serum). Should be the same as file name." + }, + "displayName": { + "type": "string", + "description": "User-readable name of the liquid class." + }, + "description": { + "type": "string", + "description": "User-readable description of the liquid class" + }, + "schemaVersion": { + "description": "Which schema version a liquid class is using", + "type": "number", + "enum": [2] + }, + "version": { + "description": "Version of the liquid class definition itself (eg water v1/v2/v3). An incrementing integer", + "type": "integer", + "minimum": 1 + }, + "namespace": { + "$ref": "#/definitions/safeString" + }, + "byPipette": { + "type": "array", + "description": "Liquid class settings by each pipette compatible with this liquid class.", + "items": { + "type": "object", + "description": "The settings for a specific kind of pipette when interacting with this liquid class", + "properties": { + "pipetteModel": { + "type": "string", + "description": "The pipette model this applies to" + }, + "byTipType": { + "type": "array", + "description": "Settings for each kind of tip this pipette can use", + "items": { + "type": "object", + "properties": { + "tiprack": { + "type": "string", + "description": "The tiprack name whose tip will be used when handling this specific liquid class with this pipette" + }, + "aspirate": { + "$ref": "#/definitions/aspirateParams" + }, + "singleDispense": { + "$ref": "#/definitions/singleDispenseParams" + }, + "multiDispense": { + "$ref": "#/definitions/multiDispenseParams" + } + }, + "required": ["tiprack", "aspirate", "singleDispense"], + "additionalProperties": false + } + } + }, + "required": ["pipetteModel", "byTipType"], + "additionalProperties": false + } + } + }, + "required": [ + "liquidClassName", + "displayName", + "description", + "schemaVersion", + "version", + "namespace", + "byPipette" + ], + "additionalProperties": false +} diff --git a/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py b/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py index 52de8550e3f..6626e01113c 100644 --- a/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py +++ b/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py @@ -1,8 +1,8 @@ """Types and functions for accessing liquid class definitions.""" from pathlib import Path from .. import load_shared_data, get_shared_data_root -from .liquid_class_definition import LiquidClassSchemaV1 - +from .liquid_class_definition import LiquidClassSchemaV1, LiquidClassSchemaV2 +from typing import Union DEFAULT_SCHEMA_VERSION = 1 DEFAULT_LC_VERSION = 1 @@ -16,13 +16,19 @@ def load_definition( name: str, version: int = DEFAULT_LC_VERSION, schema_version: int = DEFAULT_SCHEMA_VERSION, -) -> LiquidClassSchemaV1: +) -> Union[LiquidClassSchemaV2, LiquidClassSchemaV1]: """Load the specified liquid class' definition as a LiquidClassSchemaV1 object. Note: this is an expensive operation and should be called sparingly. """ + LC_TYPE: type[LiquidClassSchemaV2 | LiquidClassSchemaV1] + match schema_version: + case 1: + LC_TYPE = LiquidClassSchemaV1 + case 2: + LC_TYPE = LiquidClassSchemaV2 try: - return LiquidClassSchemaV1.model_validate_json( + return LC_TYPE.model_validate_json( load_shared_data( f"liquid-class/definitions/{schema_version}/{name}/{version}.json" ) @@ -36,9 +42,10 @@ def load_definition( def definition_exists( name: str, version: int = DEFAULT_LC_VERSION, + schema_version: int = DEFAULT_SCHEMA_VERSION, ) -> bool: """Return whether a definition exists for the specified liquid class name..""" return Path( get_shared_data_root() - / f"liquid-class/definitions/{DEFAULT_SCHEMA_VERSION}/{name}/{version}.json" + / f"liquid-class/definitions/{schema_version}/{name}/{version}.json" ).exists() diff --git a/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py b/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py index fd40c6d42fa..031da23292c 100644 --- a/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py +++ b/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py @@ -59,6 +59,9 @@ class PositionReference(Enum): WELL_BOTTOM = "well-bottom" WELL_TOP = "well-top" WELL_CENTER = "well-center" + LIQUID_MENISCUS_START = "liquid-meniscus-start" + LIQUID_MENISCUS_END = "liquid-meniscus-end" + # Same as liquid meniscus end LIQUID_MENISCUS = "liquid-meniscus" @@ -407,6 +410,24 @@ class AspirateProperties(BaseLiquidClassModel): aspiratePosition: TipPosition = Field( ..., alias="aspirate_position", description="Tip position during aspirate." ) + aspirateEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="aspirate_end_position", + description="Ending tip position during dynamic aspirate.", + json_schema_extra=_remove_default, + ) + overrideAspiratePosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_aspirate_position", + description="Tip position during aspirate if this position is not possible it will fall back to aspirate_position.", + json_schema_extra=_remove_default, + ) + overrideAspirateEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_aspirate_end_position", + description="Ending tip position during dynamic aspirate if this position is not possible it will fall back to aspirate_end_position.", + json_schema_extra=_remove_default, + ) flowRateByVolume: LiquidHandlingPropertyByVolume = Field( ..., alias="flow_rate_by_volume", @@ -439,6 +460,24 @@ class SingleDispenseProperties(BaseLiquidClassModel): dispensePosition: TipPosition = Field( ..., alias="dispense_position", description="Tip position during dispense." ) + dispenseEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="dispense_end_position", + description="Ending tip position during dynamic dispense.", + json_schema_extra=_remove_default, + ) + overrideDispensePosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_dispense_position", + description="Tip position during dispense if this position is not possible it will fall back to dispense_position.", + json_schema_extra=_remove_default, + ) + overrideDispenseEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_dispense_end_position", + description="Ending tip position during dynamic dispense if this position is not possible it will fall back to dispense_end_position.", + json_schema_extra=_remove_default, + ) flowRateByVolume: LiquidHandlingPropertyByVolume = Field( ..., alias="flow_rate_by_volume", @@ -469,6 +508,24 @@ class MultiDispenseProperties(BaseLiquidClassModel): dispensePosition: TipPosition = Field( ..., alias="dispense_position", description="Tip position during dispense." ) + dispenseEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="dispense_end_position", + description="Ending tip position during dynamic dispense.", + json_schema_extra=_remove_default, + ) + overrideDispensePosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_dispense_position", + description="Tip position during dispense if this position is not possible it will fall back to dispense_position.", + json_schema_extra=_remove_default, + ) + overrideDispenseEndPosition: TipPosition | SkipJsonSchema[None] = Field( + None, + alias="override_dispense_end_position", + description="Ending tip position during dynamic dispense if this position is not possible it will fall back to dispense_end_position.", + json_schema_extra=_remove_default, + ) flowRateByVolume: LiquidHandlingPropertyByVolume = Field( ..., alias="flow_rate_by_volume", @@ -532,7 +589,7 @@ class ByPipetteSetting(BaseLiquidClassModel): ) -class LiquidClassSchemaV1(BaseLiquidClassModel): +class LiquidClassSchemaCommon(BaseLiquidClassModel): """Defines a single liquid class's properties for liquid handling functions.""" liquidClassName: str = Field( @@ -542,9 +599,6 @@ class LiquidClassSchemaV1(BaseLiquidClassModel): description: str = Field( ..., description="User-readable description of the liquid class" ) - schemaVersion: Literal[1] = Field( - ..., description="Which schema version a liquid class is using" - ) version: int = Field( ..., description="Version of the specific liquid class definition" ) @@ -553,3 +607,22 @@ class LiquidClassSchemaV1(BaseLiquidClassModel): ..., description="Liquid class settings by each pipette compatible with this liquid class.", ) + + +class LiquidClassSchemaV1(LiquidClassSchemaCommon): + """Defines a single liquid class's properties for liquid handling functions.""" + + schemaVersion: Literal[1] = Field( + ..., description="Which schema version a liquid class is using" + ) + + +class LiquidClassSchemaV2(LiquidClassSchemaCommon): + """Defines a single liquid class's properties for liquid handling functions.""" + + schemaVersion: Literal[2] = Field( + ..., description="Which schema version a liquid class is using" + ) + + +LiquidClassSchema = Union[LiquidClassSchemaV1, LiquidClassSchemaV2] diff --git a/shared-data/python/opentrons_shared_data/liquid_classes/types.py b/shared-data/python/opentrons_shared_data/liquid_classes/types.py index c260db7f263..389f59dcf76 100644 --- a/shared-data/python/opentrons_shared_data/liquid_classes/types.py +++ b/shared-data/python/opentrons_shared_data/liquid_classes/types.py @@ -91,6 +91,9 @@ class AspiratePropertiesDict(TypedDict): correction_by_volume: Sequence[Tuple[float, float]] delay: DelayPropertiesDict aspirate_position: TipPositionDict + aspirate_end_position: NotRequired[TipPositionDict] + override_aspirate_position: NotRequired[TipPositionDict] + override_aspirate_end_position: NotRequired[TipPositionDict] retract: RetractAspirateDict pre_wet: bool mix: MixPropertiesDict @@ -104,6 +107,9 @@ class SingleDispensePropertiesDict(TypedDict): correction_by_volume: Sequence[Tuple[float, float]] delay: DelayPropertiesDict dispense_position: TipPositionDict + dispense_end_position: NotRequired[TipPositionDict] + override_dispense_position: NotRequired[TipPositionDict] + override_dispense_end_position: NotRequired[TipPositionDict] retract: RetractDispenseDict push_out_by_volume: Sequence[Tuple[float, float]] mix: MixPropertiesDict @@ -117,6 +123,9 @@ class MultiDispensePropertiesDict(TypedDict): correction_by_volume: Sequence[Tuple[float, float]] delay: DelayPropertiesDict dispense_position: TipPositionDict + dispense_end_position: NotRequired[TipPositionDict] + override_dispense_position: NotRequired[TipPositionDict] + override_dispense_end_position: NotRequired[TipPositionDict] retract: RetractDispenseDict conditioning_by_volume: Sequence[Tuple[float, float]] disposal_by_volume: Sequence[Tuple[float, float]] diff --git a/shared-data/python_tests/liquid_classes/test_load.py b/shared-data/python_tests/liquid_classes/test_load.py index 537231af258..a7eadb2d07a 100644 --- a/shared-data/python_tests/liquid_classes/test_load.py +++ b/shared-data/python_tests/liquid_classes/test_load.py @@ -4,6 +4,7 @@ from opentrons_shared_data.liquid_classes import load_definition, definition_exists from opentrons_shared_data.liquid_classes.liquid_class_definition import ( LiquidClassSchemaV1, + LiquidClassSchemaV2, PositionReference, Coordinate, TipPosition, @@ -23,6 +24,16 @@ def test_load_liquid_class_schema_v1() -> None: assert liquid_class_def_from_model == expected_liquid_class_def +def test_load_liquid_class_schema_v2() -> None: + fixture_data = load_shared_data("liquid-class/fixtures/2/fixture_glycerol50.json") + liquid_class_model = LiquidClassSchemaV2.model_validate_json(fixture_data) + liquid_class_def_from_model = json.loads( + liquid_class_model.model_dump_json(exclude_unset=True) + ) + expected_liquid_class_def = json.loads(fixture_data) + assert liquid_class_def_from_model == expected_liquid_class_def + + def test_load_definition() -> None: water_definition = load_definition(name="water", version=1, schema_version=1) assert type(water_definition) is LiquidClassSchemaV1