diff --git a/api/src/opentrons/protocol_api/_liquid_properties.py b/api/src/opentrons/protocol_api/_liquid_properties.py index 8bd7aa6cfd8..cda533b1587 100644 --- a/api/src/opentrons/protocol_api/_liquid_properties.py +++ b/api/src/opentrons/protocol_api/_liquid_properties.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Optional, Dict, Sequence +from numpy import interp +from typing import Optional, Dict, Sequence, Union, Tuple from opentrons_shared_data.liquid_classes.liquid_class_definition import ( AspirateProperties as SharedDataAspirateProperties, @@ -18,9 +19,62 @@ Coordinate, ) -# TODO replace this with a class that can extrapolate given volumes to the correct float, -# also figure out how we want people to be able to set this -LiquidHandlingPropertyByVolume = Dict[str, float] +from . import validation + + +class LiquidHandlingPropertyByVolume: + def __init__(self, properties_by_volume: Dict[str, float]) -> None: + self._default = properties_by_volume["default"] + self._properties_by_volume: Dict[float, float] = { + float(volume): value + for volume, value in properties_by_volume.items() + if volume != "default" + } + # Volumes need to be sorted for proper interpolation of non-defined volumes, and the + # corresponding values need to be in the same order for them to be interpolated correctly + self._sorted_volumes: Tuple[float, ...] = () + self._sorted_values: Tuple[float, ...] = () + self._sort_volume_and_values() + + @property + def default(self) -> float: + """Get the default value not associated with any volume for this property.""" + return self._default + + def as_dict(self) -> Dict[Union[float, str], float]: + """Get a dictionary representation of all set volumes and values along with the default.""" + return self._properties_by_volume | {"default": self._default} + + def get_for_volume(self, volume: float) -> float: + """Get a value by volume for this property. Volumes not defined will be interpolated between set volumes.""" + validated_volume = validation.ensure_positive_float(volume) + try: + return self._properties_by_volume[validated_volume] + except KeyError: + # If volume is not defined in dictionary, do a piecewise interpolation with existing sorted values + return float( + interp(validated_volume, self._sorted_volumes, self._sorted_values) + ) + + def set_for_volume(self, volume: float, value: float) -> None: + """Add a new volume and value for the property for the interpolation curve.""" + validated_volume = validation.ensure_positive_float(volume) + self._properties_by_volume[validated_volume] = value + self._sort_volume_and_values() + + def delete_for_volume(self, volume: float) -> None: + """Remove an existing volume and value from the property.""" + try: + del self._properties_by_volume[volume] + self._sort_volume_and_values() + except KeyError: + raise KeyError(f"No value set for volume {volume} uL") + + def _sort_volume_and_values(self) -> None: + """Sort volume in increasing order along with corresponding values in matching order.""" + self._sorted_volumes, self._sorted_values = zip( + *sorted(self._properties_by_volume.items()) + ) @dataclass @@ -35,10 +89,10 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and self._duration is None: + validated_enable = validation.ensure_boolean(enable) + if validated_enable and self._duration is None: raise ValueError("duration must be set before enabling delay.") - self._enabled = enable + self._enabled = validated_enable @property def duration(self) -> Optional[float]: @@ -46,8 +100,8 @@ def duration(self) -> Optional[float]: @duration.setter def duration(self, new_duration: float) -> None: - # TODO insert positive float validation here - self._duration = new_duration + validated_duration = validation.ensure_positive_float(new_duration) + self._duration = validated_duration @dataclass @@ -64,14 +118,14 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and ( + validated_enable = validation.ensure_boolean(enable) + if validated_enable and ( self._z_offset is None or self._mm_to_edge is None or self._speed is None ): raise ValueError( "z_offset, mm_to_edge and speed must be set before enabling touch tip." ) - self._enabled = enable + self._enabled = validated_enable @property def z_offset(self) -> Optional[float]: @@ -79,8 +133,8 @@ def z_offset(self) -> Optional[float]: @z_offset.setter def z_offset(self, new_offset: float) -> None: - # TODO validation for float - self._z_offset = new_offset + validated_offset = validation.ensure_float(new_offset) + self._z_offset = validated_offset @property def mm_to_edge(self) -> Optional[float]: @@ -88,8 +142,8 @@ def mm_to_edge(self) -> Optional[float]: @mm_to_edge.setter def mm_to_edge(self, new_mm: float) -> None: - # TODO validation for float - self._z_offset = new_mm + validated_mm = validation.ensure_float(new_mm) + self._z_offset = validated_mm @property def speed(self) -> Optional[float]: @@ -97,8 +151,8 @@ def speed(self) -> Optional[float]: @speed.setter def speed(self, new_speed: float) -> None: - # TODO insert positive float validation here - self._speed = new_speed + validated_speed = validation.ensure_positive_float(new_speed) + self._speed = validated_speed @dataclass @@ -114,10 +168,10 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and (self._repetitions is None or self._volume is None): + validated_enable = validation.ensure_boolean(enable) + if validated_enable and (self._repetitions is None or self._volume is None): raise ValueError("repetitions and volume must be set before enabling mix.") - self._enabled = enable + self._enabled = validated_enable @property def repetitions(self) -> Optional[int]: @@ -125,8 +179,8 @@ def repetitions(self) -> Optional[int]: @repetitions.setter def repetitions(self, new_repetitions: int) -> None: - # TODO validations for positive int - self._repetitions = new_repetitions + validated_repetitions = validation.ensure_positive_int(new_repetitions) + self._repetitions = validated_repetitions @property def volume(self) -> Optional[float]: @@ -134,8 +188,8 @@ def volume(self) -> Optional[float]: @volume.setter def volume(self, new_volume: float) -> None: - # TODO validations for volume float - self._volume = new_volume + validated_volume = validation.ensure_positive_float(new_volume) + self._volume = validated_volume @dataclass @@ -151,12 +205,12 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and (self._location is None or self._flow_rate is None): + validated_enable = validation.ensure_boolean(enable) + if validated_enable and (self._location is None or self._flow_rate is None): raise ValueError( "location and flow_rate must be set before enabling blowout." ) - self._enabled = enable + self._enabled = validated_enable @property def location(self) -> Optional[BlowoutLocation]: @@ -164,7 +218,6 @@ def location(self) -> Optional[BlowoutLocation]: @location.setter def location(self, new_location: str) -> None: - # TODO blowout location validation self._location = BlowoutLocation(new_location) @property @@ -173,8 +226,8 @@ def flow_rate(self) -> Optional[float]: @flow_rate.setter def flow_rate(self, new_flow_rate: float) -> None: - # TODO validations for positive float - self._flow_rate = new_flow_rate + validated_flow_rate = validation.ensure_positive_float(new_flow_rate) + self._flow_rate = validated_flow_rate @dataclass @@ -191,7 +244,6 @@ def position_reference(self) -> PositionReference: @position_reference.setter def position_reference(self, new_position: str) -> None: - # TODO validation for position reference self._position_reference = PositionReference(new_position) @property @@ -200,8 +252,8 @@ def offset(self) -> Coordinate: @offset.setter def offset(self, new_offset: Sequence[float]) -> None: - # TODO validate valid coordinates - self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + x, y, z = validation.validate_coordinates(new_offset) + self._offset = Coordinate(x=x, y=y, z=z) @property def speed(self) -> float: @@ -209,8 +261,8 @@ def speed(self) -> float: @speed.setter def speed(self, new_speed: float) -> None: - # TODO insert positive float validation here - self._speed = new_speed + validated_speed = validation.ensure_positive_float(new_speed) + self._speed = validated_speed @property def delay(self) -> DelayProperties: @@ -276,7 +328,6 @@ def position_reference(self) -> PositionReference: @position_reference.setter def position_reference(self, new_position: str) -> None: - # TODO validation for position reference self._position_reference = PositionReference(new_position) @property @@ -285,8 +336,8 @@ def offset(self) -> Coordinate: @offset.setter def offset(self, new_offset: Sequence[float]) -> None: - # TODO validate valid coordinates - self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + x, y, z = validation.validate_coordinates(new_offset) + self._offset = Coordinate(x=x, y=y, z=z) @property def flow_rate_by_volume(self) -> LiquidHandlingPropertyByVolume: @@ -310,8 +361,8 @@ def pre_wet(self) -> bool: @pre_wet.setter def pre_wet(self, new_setting: bool) -> None: - # TODO boolean validation - self._pre_wet = new_setting + validated_setting = validation.ensure_boolean(new_setting) + self._pre_wet = validated_setting @property def retract(self) -> RetractAspirate: @@ -362,8 +413,6 @@ def disposal_by_volume(self) -> LiquidHandlingPropertyByVolume: return self._disposal_by_volume -# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties -# and have validation on value updates with user-facing error messages @dataclass class TransferProperties: _aspirate: AspirateProperties @@ -461,7 +510,9 @@ def _build_retract_aspirate( _position_reference=retract_aspirate.positionReference, _offset=retract_aspirate.offset, _speed=retract_aspirate.speed, - _air_gap_by_volume=retract_aspirate.airGapByVolume, + _air_gap_by_volume=LiquidHandlingPropertyByVolume( + retract_aspirate.airGapByVolume + ), _touch_tip=_build_touch_tip_properties(retract_aspirate.touchTip), _delay=_build_delay_properties(retract_aspirate.delay), ) @@ -474,7 +525,9 @@ def _build_retract_dispense( _position_reference=retract_dispense.positionReference, _offset=retract_dispense.offset, _speed=retract_dispense.speed, - _air_gap_by_volume=retract_dispense.airGapByVolume, + _air_gap_by_volume=LiquidHandlingPropertyByVolume( + retract_dispense.airGapByVolume + ), _blowout=_build_blowout_properties(retract_dispense.blowout), _touch_tip=_build_touch_tip_properties(retract_dispense.touchTip), _delay=_build_delay_properties(retract_dispense.delay), @@ -489,7 +542,9 @@ def build_aspirate_properties( _retract=_build_retract_aspirate(aspirate_properties.retract), _position_reference=aspirate_properties.positionReference, _offset=aspirate_properties.offset, - _flow_rate_by_volume=aspirate_properties.flowRateByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + aspirate_properties.flowRateByVolume + ), _pre_wet=aspirate_properties.preWet, _mix=_build_mix_properties(aspirate_properties.mix), _delay=_build_delay_properties(aspirate_properties.delay), @@ -504,9 +559,13 @@ def build_single_dispense_properties( _retract=_build_retract_dispense(single_dispense_properties.retract), _position_reference=single_dispense_properties.positionReference, _offset=single_dispense_properties.offset, - _flow_rate_by_volume=single_dispense_properties.flowRateByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + single_dispense_properties.flowRateByVolume + ), _mix=_build_mix_properties(single_dispense_properties.mix), - _push_out_by_volume=single_dispense_properties.pushOutByVolume, + _push_out_by_volume=LiquidHandlingPropertyByVolume( + single_dispense_properties.pushOutByVolume + ), _delay=_build_delay_properties(single_dispense_properties.delay), ) @@ -521,9 +580,15 @@ def build_multi_dispense_properties( _retract=_build_retract_dispense(multi_dispense_properties.retract), _position_reference=multi_dispense_properties.positionReference, _offset=multi_dispense_properties.offset, - _flow_rate_by_volume=multi_dispense_properties.flowRateByVolume, - _conditioning_by_volume=multi_dispense_properties.conditioningByVolume, - _disposal_by_volume=multi_dispense_properties.disposalByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.flowRateByVolume + ), + _conditioning_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.conditioningByVolume + ), + _disposal_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.disposalByVolume + ), _delay=_build_delay_properties(multi_dispense_properties.delay), ) diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index fa33812441e..44123571081 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -11,7 +11,7 @@ NamedTuple, TYPE_CHECKING, ) - +from math import isinf, isnan from typing_extensions import TypeGuard from opentrons_shared_data.labware.labware_definition import LabwareRole @@ -592,3 +592,45 @@ def validate_location( if well is not None else PointTarget(location=target_location, in_place=in_place) ) + + +def ensure_boolean(value: bool) -> bool: + """Ensure value is a boolean.""" + if not isinstance(value, bool): + raise ValueError("Value must be a boolean.") + return value + + +def ensure_float(value: Union[int, float]) -> float: + """Ensure value is a float (or an integer) and return it as a float.""" + if not isinstance(value, (int, float)): + raise ValueError("Value must be a floating point number.") + return float(value) + + +def ensure_positive_float(value: Union[int, float]) -> float: + """Ensure value is a positive and real float value.""" + float_value = ensure_float(value) + if isnan(float_value) or isinf(float_value): + raise ValueError("Value must be a defined, non-infinite number.") + if float_value < 0: + raise ValueError("Value must be a positive float.") + return float_value + + +def ensure_positive_int(value: int) -> int: + """Ensure value is a positive integer.""" + if not isinstance(value, int): + raise ValueError("Value must be an integer.") + if value < 0: + raise ValueError("Value must be a positive integer.") + return value + + +def validate_coordinates(value: Sequence[float]) -> Tuple[float, float, float]: + """Ensure value is a valid sequence of 3 floats and return a tuple of 3 floats.""" + if len(value) != 3: + raise ValueError("Coordinates must be a sequence of exactly three numbers") + if not all(isinstance(v, (float, int)) for v in value): + raise ValueError("All values in coordinates must be floats.") + return float(value[0]), float(value[1]), float(value[2]) diff --git a/api/tests/opentrons/protocol_api/test_liquid_class.py b/api/tests/opentrons/protocol_api/test_liquid_class.py index be0b432e32f..463889b3da6 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class.py @@ -22,7 +22,11 @@ def test_get_for_pipette_and_tip( """It should get the properties for the specified pipette and tip.""" liq_class = LiquidClass.create(minimal_liquid_class_def2) result = liq_class.get_for("p20_single_gen2", "opentrons_96_tiprack_20ul") - assert result.aspirate.flow_rate_by_volume == {"default": 50, "10": 40, "20": 30} + assert result.aspirate.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, + } def test_get_for_raises_for_incorrect_pipette_or_tip( 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 b1699701f3c..7e9d7cc2f3b 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py @@ -1,5 +1,5 @@ """Tests for LiquidClass properties and related functions.""" - +import pytest from opentrons_shared_data import load_shared_data from opentrons_shared_data.liquid_classes.liquid_class_definition import ( LiquidClassSchemaV1, @@ -10,6 +10,7 @@ build_aspirate_properties, build_single_dispense_properties, build_multi_dispense_properties, + LiquidHandlingPropertyByVolume, ) @@ -30,10 +31,10 @@ def test_build_aspirate_settings() -> None: assert aspirate_properties.retract.position_reference.value == "well-top" assert aspirate_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert aspirate_properties.retract.speed == 100 - assert aspirate_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert aspirate_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert aspirate_properties.retract.touch_tip.enabled is True assert aspirate_properties.retract.touch_tip.z_offset == 2 @@ -44,10 +45,10 @@ def test_build_aspirate_settings() -> None: assert aspirate_properties.position_reference.value == "well-bottom" assert aspirate_properties.offset == Coordinate(x=0, y=0, z=-5) - assert aspirate_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert aspirate_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } assert aspirate_properties.pre_wet is True assert aspirate_properties.mix.enabled is True @@ -77,10 +78,10 @@ def test_build_single_dispense_settings() -> None: assert single_dispense_properties.retract.position_reference.value == "well-top" assert single_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert single_dispense_properties.retract.speed == 100 - assert single_dispense_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert single_dispense_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert single_dispense_properties.retract.touch_tip.enabled is True assert single_dispense_properties.retract.touch_tip.z_offset == 2 @@ -95,18 +96,18 @@ def test_build_single_dispense_settings() -> None: assert single_dispense_properties.position_reference.value == "well-bottom" assert single_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) - assert single_dispense_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert single_dispense_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } assert single_dispense_properties.mix.enabled is True assert single_dispense_properties.mix.repetitions == 3 assert single_dispense_properties.mix.volume == 15 - assert single_dispense_properties.push_out_by_volume == { - "default": 5, - "10": 7, - "20": 10, + assert single_dispense_properties.push_out_by_volume.as_dict() == { + "default": 5.0, + 10.0: 7.0, + 20.0: 10.0, } assert single_dispense_properties.delay.enabled is True assert single_dispense_properties.delay.duration == 2.5 @@ -133,10 +134,10 @@ def test_build_multi_dispense_settings() -> None: assert multi_dispense_properties.retract.position_reference.value == "well-top" assert multi_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert multi_dispense_properties.retract.speed == 100 - assert multi_dispense_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert multi_dispense_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert multi_dispense_properties.retract.touch_tip.enabled is True assert multi_dispense_properties.retract.touch_tip.z_offset == 2 @@ -150,18 +151,18 @@ def test_build_multi_dispense_settings() -> None: assert multi_dispense_properties.position_reference.value == "well-bottom" assert multi_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) - assert multi_dispense_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert multi_dispense_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } - assert multi_dispense_properties.conditioning_by_volume == { - "default": 10, - "5": 5, + assert multi_dispense_properties.conditioning_by_volume.as_dict() == { + "default": 10.0, + 5.0: 5.0, } - assert multi_dispense_properties.disposal_by_volume == { - "default": 2, - "5": 3, + assert multi_dispense_properties.disposal_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, } assert multi_dispense_properties.delay.enabled is True assert multi_dispense_properties.delay.duration == 1 @@ -173,3 +174,31 @@ def test_build_multi_dispense_settings_none( """It should return None if there are no multi dispense properties in the model.""" transfer_settings = minimal_liquid_class_def2.byPipette[0].byTipType[0] assert build_multi_dispense_properties(transfer_settings.multiDispense) is None + + +def test_liquid_handling_property_by_volume() -> None: + """It should create a class that can interpolate values and add and delete new points.""" + subject = LiquidHandlingPropertyByVolume({"default": 42, "5": 50, "10.0": 250}) + assert subject.as_dict() == {"default": 42, 5.0: 50, 10.0: 250} + assert subject.default == 42.0 + assert subject.get_for_volume(7) == 130.0 + + subject.set_for_volume(volume=7, value=175.5) + assert subject.as_dict() == { + "default": 42, + 5.0: 50, + 10.0: 250, + 7.0: 175.5, + } + assert subject.get_for_volume(7) == 175.5 + + subject.delete_for_volume(7) + assert subject.as_dict() == {"default": 42, 5.0: 50, 10.0: 250} + assert subject.get_for_volume(7) == 130.0 + + with pytest.raises(KeyError, match="No value set for volume"): + subject.delete_for_volume(7) + + # Test bounds + assert subject.get_for_volume(1) == 50.0 + assert subject.get_for_volume(1000) == 250.0 diff --git a/api/tests/opentrons/protocol_api/test_validation.py b/api/tests/opentrons/protocol_api/test_validation.py index c7f35a1519e..9a111e6f81f 100644 --- a/api/tests/opentrons/protocol_api/test_validation.py +++ b/api/tests/opentrons/protocol_api/test_validation.py @@ -1,5 +1,5 @@ """Tests for Protocol API input validation.""" -from typing import ContextManager, List, Type, Union, Optional, Dict, Any +from typing import ContextManager, List, Type, Union, Optional, Dict, Sequence, Any from contextlib import nullcontext as do_not_raise from decoy import Decoy @@ -465,7 +465,7 @@ def test_validate_well_no_location(decoy: Decoy) -> None: assert result == expected_result -def test_validate_coordinates(decoy: Decoy) -> None: +def test_validate_well_coordinates(decoy: Decoy) -> None: """Should return a WellTarget with no location.""" input_location = Location(point=Point(x=1, y=1, z=2), labware=None) expected_result = subject.PointTarget(location=input_location, in_place=False) @@ -570,6 +570,67 @@ def test_validate_last_location_with_labware(decoy: Decoy) -> None: assert result == subject.PointTarget(location=input_last_location, in_place=True) +def test_ensure_boolean() -> None: + """It should return a boolean value.""" + assert subject.ensure_boolean(False) is False + + +@pytest.mark.parametrize("value", [0, "False", "f", 0.0]) +def test_ensure_boolean_raises(value: Union[str, int, float]) -> None: + """It should raise if the value is not a boolean.""" + with pytest.raises(ValueError, match="must be a boolean"): + subject.ensure_boolean(value) # type: ignore[arg-type] + + +@pytest.mark.parametrize("value", [-1.23, -1, 0, 0.0, 1, 1.23]) +def test_ensure_float(value: Union[int, float]) -> None: + """It should return a float value.""" + assert subject.ensure_float(value) == float(value) + + +def test_ensure_float_raises() -> None: + """It should raise if the value is not a float or an integer.""" + with pytest.raises(ValueError, match="must be a floating point"): + subject.ensure_float("1.23") # type: ignore[arg-type] + + +@pytest.mark.parametrize("value", [0, 0.1, 1, 1.0]) +def test_ensure_positive_float(value: Union[int, float]) -> None: + """It should return a positive float.""" + assert subject.ensure_positive_float(value) == float(value) + + +@pytest.mark.parametrize("value", [-1, -1.0, float("inf"), float("-inf"), float("nan")]) +def test_ensure_positive_float_raises(value: Union[int, float]) -> None: + """It should raise if value is not a positive float.""" + with pytest.raises(ValueError, match="(non-infinite|positive float)"): + subject.ensure_positive_float(value) + + +def test_ensure_positive_int() -> None: + """It should return a positive int.""" + assert subject.ensure_positive_int(42) == 42 + + +@pytest.mark.parametrize("value", [1.0, -1.0, -1]) +def test_ensure_positive_int_raises(value: Union[int, float]) -> None: + """It should raise if value is not a positive integer.""" + with pytest.raises(ValueError, match="integer"): + subject.ensure_positive_int(value) # type: ignore[arg-type] + + +def test_validate_coordinates() -> None: + """It should validate the coordinates and return them as a tuple.""" + assert subject.validate_coordinates([1, 2.0, 3.3]) == (1.0, 2.0, 3.3) + + +@pytest.mark.parametrize("value", [[1, 2.0], [1, 2.0, 3.3, 4.2], ["1", 2, 3]]) +def test_validate_coordinates_raises(value: Sequence[Union[int, float, str]]) -> None: + """It should raise if value is not a valid sequence of three numbers.""" + with pytest.raises(ValueError, match="(exactly three|must be floats)"): + subject.validate_coordinates(value) # type: ignore[arg-type] + + @pytest.mark.parametrize( argnames=["axis_map", "robot_type", "is_96_channel", "expected_axis_map"], argvalues=[ diff --git a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py index eed90cc2478..6621a790801 100644 --- a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py +++ b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py @@ -27,7 +27,7 @@ def test_liquid_class_creation_and_property_fetching( assert ( glycerol_50.get_for( pipette_left.name, tiprack.load_name - ).dispense.flow_rate_by_volume["default"] + ).dispense.flow_rate_by_volume.default == 50 ) assert (