Skip to content

Commit 989792e

Browse files
shiyaochenshiyaochen
authored andcommitted
Merge branch 'edge' into pd-submerge-aspirate
2 parents c0934b9 + 3c23793 commit 989792e

File tree

22 files changed

+347
-65
lines changed

22 files changed

+347
-65
lines changed

api/release-notes-internal.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ This internal release, pulled from the `edge` branch, contains features being de
1111
- Python API version bumped to 2.23
1212
- Added liquid classes and new transfer functions
1313

14+
### Bug Fixes In This Release (list in progress):
15+
- Fixed `InstrumentContext.name` so that it returns the correct API-specific names of Flex pipettes.
16+
17+
1418
## Internal Release 2.3.0-alpha.2
1519

1620
This internal release, pulled from the `edge` branch, contains features being developed for 8.3.0. It's for internal testing only.

api/src/opentrons/protocol_api/_liquid.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4-
from typing import Optional, Dict
4+
from typing import Optional, Dict, Union, TYPE_CHECKING
55

66
from opentrons_shared_data.liquid_classes.liquid_class_definition import (
77
LiquidClassSchemaV1,
@@ -12,6 +12,9 @@
1212
build_transfer_properties,
1313
)
1414

15+
if TYPE_CHECKING:
16+
from . import InstrumentContext, Labware
17+
1518

1619
@dataclass(frozen=True)
1720
class Liquid:
@@ -64,18 +67,42 @@ def name(self) -> str:
6467
def display_name(self) -> str:
6568
return self._display_name
6669

67-
def get_for(self, pipette: str, tiprack: str) -> TransferProperties:
70+
def get_for(
71+
self, pipette: Union[str, InstrumentContext], tip_rack: Union[str, Labware]
72+
) -> TransferProperties:
6873
"""Get liquid class transfer properties for the specified pipette and tip."""
74+
from . import InstrumentContext, Labware
75+
76+
if isinstance(pipette, InstrumentContext):
77+
pipette_name = pipette.name
78+
elif isinstance(pipette, str):
79+
pipette_name = pipette
80+
else:
81+
raise ValueError(
82+
f"{pipette} should either be an InstrumentContext object"
83+
f" or a pipette name string."
84+
)
85+
86+
if isinstance(tip_rack, Labware):
87+
tiprack_uri = tip_rack.uri
88+
elif isinstance(tip_rack, str):
89+
tiprack_uri = tip_rack
90+
else:
91+
raise ValueError(
92+
f"{tip_rack} should either be a tiprack Labware object"
93+
f" or a tiprack URI string."
94+
)
95+
6996
try:
70-
settings_for_pipette = self._by_pipette_setting[pipette]
97+
settings_for_pipette = self._by_pipette_setting[pipette_name]
7198
except KeyError:
7299
raise ValueError(
73-
f"No properties found for {pipette} in {self._name} liquid class"
100+
f"No properties found for {pipette_name} in {self._name} liquid class"
74101
)
75102
try:
76-
transfer_properties = settings_for_pipette[tiprack]
103+
transfer_properties = settings_for_pipette[tiprack_uri]
77104
except KeyError:
78105
raise ValueError(
79-
f"No properties found for {tiprack} in {self._name} liquid class"
106+
f"No properties found for {tiprack_uri} in {self._name} liquid class"
80107
)
81108
return transfer_properties

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

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError
4343
from opentrons.protocol_engine.clients import SyncClient as EngineClient
4444
from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION
45-
from opentrons_shared_data.pipette.types import PipetteNameType, PIPETTE_API_NAMES_MAP
45+
from opentrons_shared_data.pipette.types import PIPETTE_API_NAMES_MAP
4646
from opentrons_shared_data.errors.exceptions import (
4747
UnsupportedHardwareCommand,
4848
)
@@ -62,6 +62,9 @@
6262

6363
_DISPENSE_VOLUME_VALIDATION_ADDED_IN = APIVersion(2, 17)
6464

65+
_FLEX_PIPETTE_NAMES_FIXED_IN = APIVersion(2, 23)
66+
"""The version after which InstrumentContext.name returns the correct API-specific names of Flex pipettes."""
67+
6568

6669
class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
6770
"""Instrument API core using a ProtocolEngine.
@@ -721,33 +724,29 @@ def get_pipette_name(self) -> str:
721724
722725
Will match the load name of the actually loaded pipette,
723726
which may differ from the requested load name.
724-
"""
725-
# TODO (tz, 11-23-22): revert this change when merging
726-
# https://opentrons.atlassian.net/browse/RLIQ-251
727-
pipette = self._engine_client.state.pipettes.get(self._pipette_id)
728-
return (
729-
pipette.pipetteName.value
730-
if isinstance(pipette.pipetteName, PipetteNameType)
731-
else pipette.pipetteName
732-
)
733727
734-
def get_load_name(self) -> str:
735-
"""Get the pipette's requested API load name.
728+
From API v2.15 to v2.22, this property returned an internal, engine-specific,
729+
name for Flex pipettes (eg, "p50_multi_flex" instead of "flex_8channel_50").
736730
737-
This is the load name that is specified in the `ProtocolContext.load_instrument()`
738-
method. This name might differ from the engine-specific pipette name.
731+
From API v2.23 onwards, this behavior is fixed so that this property returns
732+
the API-specific names of Flex pipettes.
739733
"""
734+
# TODO (tz, 11-23-22): revert this change when merging
735+
# https://opentrons.atlassian.net/browse/RLIQ-251
740736
pipette = self._engine_client.state.pipettes.get(self._pipette_id)
741-
load_name = next(
742-
(
743-
pip_api_name
744-
for pip_api_name, pip_name in PIPETTE_API_NAMES_MAP.items()
745-
if pip_name == pipette.pipetteName
746-
),
747-
None,
748-
)
749-
assert load_name, "Load name not found."
750-
return load_name
737+
if self._protocol_core.api_version < _FLEX_PIPETTE_NAMES_FIXED_IN:
738+
return pipette.pipetteName.value
739+
else:
740+
name = next(
741+
(
742+
pip_api_name
743+
for pip_api_name, pip_name in PIPETTE_API_NAMES_MAP.items()
744+
if pip_name == pipette.pipetteName
745+
),
746+
None,
747+
)
748+
assert name, "Pipette name not found."
749+
return name
751750

752751
def get_model(self) -> str:
753752
return self._engine_client.state.pipettes.get_model_name(self._pipette_id)
@@ -932,7 +931,7 @@ def load_liquid_class(
932931
"""
933932
liquid_class_record = LiquidClassRecord(
934933
liquidClassName=name,
935-
pipetteModel=self.get_load_name(),
934+
pipetteModel=self.get_pipette_name(),
936935
tiprack=tiprack_uri,
937936
aspirate=transfer_properties.aspirate.as_shared_data_model(),
938937
singleDispense=transfer_properties.dispense.as_shared_data_model(),
@@ -994,8 +993,7 @@ def transfer_liquid( # noqa: C901
994993
)
995994
tiprack_uri_for_transfer_props = tip_racks[0][1].get_uri()
996995
transfer_props = liquid_class.get_for(
997-
pipette=self.get_load_name(),
998-
tiprack=tiprack_uri_for_transfer_props,
996+
pipette=self.get_pipette_name(), tip_rack=tiprack_uri_for_transfer_props
999997
)
1000998
# TODO: use the ID returned by load_liquid_class in command annotations
1001999
self.load_liquid_class(
@@ -1008,7 +1006,12 @@ def transfer_liquid( # noqa: C901
10081006
source_dest_per_volume_step = tx_commons.expand_for_volume_constraints(
10091007
volumes=[volume for _ in range(len(source))],
10101008
targets=zip(source, dest),
1011-
max_volume=self.get_max_volume(),
1009+
max_volume=min(
1010+
self.get_max_volume(),
1011+
tip_racks[0][1]
1012+
.get_well_core("A1")
1013+
.get_max_volume(), # Assuming all tips in tiprack are of same volume
1014+
),
10121015
)
10131016

10141017
def _drop_tip() -> None:
@@ -1175,10 +1178,11 @@ def aspirate_liquid_class(
11751178
Return: List of liquid and air gap pairs in tip.
11761179
"""
11771180
aspirate_props = transfer_properties.aspirate
1181+
# TODO (spp, 2025-01-30): check if check_valid_volume_parameters is necessary and is enough.
11781182
tx_commons.check_valid_volume_parameters(
11791183
disposal_volume=0, # No disposal volume for 1-to-1 transfer
11801184
air_gap=aspirate_props.retract.air_gap_by_volume.get_for_volume(volume),
1181-
max_volume=self.get_max_volume(),
1185+
max_volume=self.get_working_volume(),
11821186
)
11831187
source_loc, source_well = source
11841188
aspirate_point = (

api/src/opentrons/protocol_api/instrument_context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
_AIR_GAP_TRACKING_ADDED_IN = APIVersion(2, 22)
6565
"""The version after which air gaps should be implemented with a separate call instead of an aspirate for better liquid volume tracking."""
6666

67+
6768
AdvancedLiquidHandling = v1_transfer.AdvancedLiquidHandling
6869

6970

@@ -2004,6 +2005,10 @@ def trash_container(
20042005
def name(self) -> str:
20052006
"""
20062007
The name string for the pipette (e.g., ``"p300_single"``).
2008+
2009+
From API v2.15 to v2.22, this property returned an internal name for Flex pipettes.
2010+
From API v2.23 onwards, this behavior is fixed so that this property returns
2011+
the Python Protocol API load names of Flex pipettes.
20072012
"""
20082013
return self._core.get_pipette_name()
20092014

api/src/opentrons/protocols/api_support/instrument.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,20 @@ def validate_tiprack(
9999
gen_lookup = (
100100
"FLEX" if ("flex" in instr_metadata or "96" in instr_metadata) else "OT2"
101101
)
102-
valid_vols = VALID_PIP_TIPRACK_VOL[gen_lookup][instrument_name.split("_")[0]]
102+
103+
# TODO (spp, 2025-01-30): do what AA's note above says or at least,
104+
# fetch the 'pip_type' below from the 'model' field in pipette definitions
105+
# so that we don't have to figure it out from pipette names
106+
if instrument_name.split("_")[0] == "flex":
107+
# Flex's API load names have the format 'flex_1channel_1000'
108+
# From API v2.23 on, this is the name returned by InstrumentContext.name
109+
pip_type = "p" + instrument_name.split("_")[2]
110+
else:
111+
# Until API v2.23, InstrumentContext.name returned the engine-specific names
112+
# of Flex pipettes. These names, as well as OT2 pipette names,
113+
# have the format- 'p1000_single_gen2' or 'p1000_single_flex'
114+
pip_type = instrument_name.split("_")[0]
115+
valid_vols = VALID_PIP_TIPRACK_VOL[gen_lookup][pip_type]
103116
if tiprack_vol not in valid_vols:
104117
log.warning(
105118
f"The pipette {instrument_name} and its tip rack {tip_rack.load_name}"

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

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,32 +187,60 @@ def test_pipette_id(subject: InstrumentCore) -> None:
187187
assert subject.pipette_id == "abc123"
188188

189189

190-
def test_get_pipette_name(
191-
decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore
190+
@pytest.mark.parametrize(
191+
"version",
192+
[
193+
APIVersion(2, 15),
194+
APIVersion(2, 17),
195+
APIVersion(2, 20),
196+
APIVersion(2, 22),
197+
],
198+
)
199+
def test_get_pipette_name_old(
200+
decoy: Decoy,
201+
mock_engine_client: EngineClient,
202+
mock_protocol_core: ProtocolCore,
203+
subject: InstrumentCore,
204+
version: APIVersion,
192205
) -> None:
193206
"""It should get the pipette's load name."""
207+
decoy.when(mock_protocol_core.api_version).then_return(version)
194208
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
195209
LoadedPipette.model_construct(pipetteName=PipetteNameType.P300_SINGLE) # type: ignore[call-arg]
196210
)
197-
198-
result = subject.get_pipette_name()
199-
200-
assert result == "p300_single"
211+
assert subject.get_pipette_name() == "p300_single"
212+
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
213+
LoadedPipette.model_construct(pipetteName=PipetteNameType.P1000_96) # type: ignore[call-arg]
214+
)
215+
assert subject.get_pipette_name() == "p1000_96"
216+
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
217+
LoadedPipette.model_construct(pipetteName=PipetteNameType.P50_SINGLE_FLEX) # type: ignore[call-arg]
218+
)
219+
assert subject.get_pipette_name() == "p50_single_flex"
201220

202221

203-
def test_get_pipette_load_name(
204-
decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore
222+
@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23)))
223+
def test_get_pipette_name_new(
224+
decoy: Decoy,
225+
mock_engine_client: EngineClient,
226+
mock_protocol_core: ProtocolCore,
227+
subject: InstrumentCore,
228+
version: APIVersion,
205229
) -> None:
206230
"""It should get the pipette's API-specific load name."""
231+
decoy.when(mock_protocol_core.api_version).then_return(version)
207232
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
208233
LoadedPipette.model_construct(pipetteName=PipetteNameType.P300_SINGLE) # type: ignore[call-arg]
209234
)
210-
assert subject.get_load_name() == "p300_single"
211-
235+
assert subject.get_pipette_name() == "p300_single"
212236
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
213237
LoadedPipette.model_construct(pipetteName=PipetteNameType.P1000_96) # type: ignore[call-arg]
214238
)
215-
assert subject.get_load_name() == "flex_96channel_1000"
239+
assert subject.get_pipette_name() == "flex_96channel_1000"
240+
decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return(
241+
LoadedPipette.model_construct(pipetteName=PipetteNameType.P50_SINGLE_FLEX) # type: ignore[call-arg]
242+
)
243+
assert subject.get_pipette_name() == "flex_1channel_50"
216244

217245

218246
def test_get_mount(
@@ -1671,11 +1699,14 @@ def test_liquid_probe_with_recovery(
16711699
)
16721700

16731701

1702+
@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23)))
16741703
def test_load_liquid_class(
16751704
decoy: Decoy,
16761705
mock_engine_client: EngineClient,
1706+
mock_protocol_core: ProtocolCore,
16771707
subject: InstrumentCore,
16781708
minimal_liquid_class_def2: LiquidClassSchemaV1,
1709+
version: APIVersion,
16791710
) -> None:
16801711
"""It should send the load liquid class command to the engine."""
16811712
sample_aspirate_data = minimal_liquid_class_def2.byPipette[0].byTipType[0].aspirate
@@ -1686,6 +1717,7 @@ def test_load_liquid_class(
16861717
minimal_liquid_class_def2.byPipette[0].byTipType[0].multiDispense
16871718
)
16881719

1720+
decoy.when(mock_protocol_core.api_version).then_return(version)
16891721
test_liq_class = decoy.mock(cls=LiquidClass)
16901722
test_transfer_props = decoy.mock(cls=TransferProperties)
16911723

@@ -1786,6 +1818,42 @@ def test_aspirate_liquid_class(
17861818
assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)]
17871819

17881820

1821+
def test_aspirate_liquid_class_raises_for_more_than_max_volume(
1822+
decoy: Decoy,
1823+
mock_engine_client: EngineClient,
1824+
subject: InstrumentCore,
1825+
minimal_liquid_class_def2: LiquidClassSchemaV1,
1826+
mock_transfer_components_executor: TransferComponentsExecutor,
1827+
) -> None:
1828+
"""It should call aspirate sub-steps execution based on liquid class."""
1829+
source_well = decoy.mock(cls=WellCore)
1830+
source_location = Location(Point(1, 2, 3), labware=None)
1831+
test_liquid_class = LiquidClass.create(minimal_liquid_class_def2)
1832+
test_transfer_properties = test_liquid_class.get_for(
1833+
"flex_1channel_50", "opentrons_flex_96_tiprack_50ul"
1834+
)
1835+
decoy.when(
1836+
mock_engine_client.state.pipettes.get_working_volume("abc123")
1837+
).then_return(100)
1838+
decoy.when(
1839+
tx_commons.check_valid_volume_parameters(
1840+
disposal_volume=0,
1841+
air_gap=test_transfer_properties.aspirate.retract.air_gap_by_volume.get_for_volume(
1842+
123
1843+
),
1844+
max_volume=100,
1845+
)
1846+
).then_raise(ValueError("Oh oh!"))
1847+
with pytest.raises(ValueError, match="Oh oh!"):
1848+
subject.aspirate_liquid_class(
1849+
volume=123,
1850+
source=(source_location, source_well),
1851+
transfer_properties=test_transfer_properties,
1852+
transfer_type=TransferType.ONE_TO_ONE,
1853+
tip_contents=[],
1854+
)
1855+
1856+
17891857
def test_dispense_liquid_class(
17901858
decoy: Decoy,
17911859
mock_engine_client: EngineClient,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def sample_transfer_props(
3636
) -> TransferProperties:
3737
"""Return a mocked out liquid class fixture."""
3838
return LiquidClass.create(maximal_liquid_class_def).get_for(
39-
pipette="flex_1channel_50", tiprack="opentrons_flex_96_tiprack_50ul"
39+
pipette="flex_1channel_50", tip_rack="opentrons_flex_96_tiprack_50ul"
4040
)
4141

4242

0 commit comments

Comments
 (0)