Skip to content

Commit 3c23793

Browse files
feat(api): fix InstrumentContext.name for Flex and update LiquidClass.get_for() (#17289)
Closes AUTH-1295, AUTH-1299 # Overview - fixes `InstrumentContext.name` to fetch the python API load name of flex pipettes - updates `LiquidClass.get_for()` to accept the a loaded instrument object and loaded tiprack object for `pipette` and `tip_rack` args respectively. - changes the tiprack argument name of `get_for()` from `tiprack` to `tip_rack` to be consistent with API naming conventions. ## Risk assessment None --------- Co-authored-by: Max Marrone <[email protected]>
1 parent a79e873 commit 3c23793

File tree

10 files changed

+140
-51
lines changed

10 files changed

+140
-51
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: 25 additions & 27 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(

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: 43 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

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

api/tests/opentrons/protocol_api/test_liquid_class.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Tests for LiquidClass methods."""
22
import pytest
3+
from decoy import Decoy
34

45
from opentrons_shared_data.liquid_classes.liquid_class_definition import (
56
LiquidClassSchemaV1,
67
)
78
from opentrons.protocol_api import LiquidClass
9+
from opentrons.protocol_api import InstrumentContext, Labware
810

911

1012
def test_create_liquid_class(
@@ -17,6 +19,7 @@ def test_create_liquid_class(
1719

1820

1921
def test_get_for_pipette_and_tip(
22+
decoy: Decoy,
2023
minimal_liquid_class_def2: LiquidClassSchemaV1,
2124
) -> None:
2225
"""It should get the properties for the specified pipette and tip."""
@@ -26,6 +29,15 @@ def test_get_for_pipette_and_tip(
2629
10.0: 40.0,
2730
20.0: 30.0,
2831
}
32+
mock_instrument = decoy.mock(cls=InstrumentContext)
33+
mock_tiprack = decoy.mock(cls=Labware)
34+
decoy.when(mock_instrument.name).then_return("flex_1channel_50")
35+
decoy.when(mock_tiprack.uri).then_return("opentrons_flex_96_tiprack_50ul")
36+
result_2 = liq_class.get_for(mock_instrument, mock_tiprack)
37+
assert result_2.aspirate.flow_rate_by_volume.as_dict() == {
38+
10.0: 40.0,
39+
20.0: 30.0,
40+
}
2941

3042

3143
def test_get_for_raises_for_incorrect_pipette_or_tip(

api/tests/opentrons/protocol_api/test_protocol_context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def test_load_instrument(
283283
).then_return(mock_instrument_core)
284284

285285
decoy.when(mock_instrument_core.get_pipette_name()).then_return("Gandalf the Grey")
286+
decoy.when(mock_instrument_core.get_model()).then_return("wizard")
286287
decoy.when(mock_core.get_disposal_locations()).then_raise(
287288
NoTrashDefinedError("No trash!")
288289
)

api/tests/opentrons/protocol_api_integration/test_liquid_classes.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def test_liquid_class_creation_and_property_fetching(
1313
) -> None:
1414
"""It should create the liquid class and provide access to its properties."""
1515
pipette_load_name = "flex_8channel_50"
16-
simulated_protocol_context.load_instrument(pipette_load_name, mount="left")
16+
p50 = simulated_protocol_context.load_instrument(pipette_load_name, mount="left")
1717
tiprack = simulated_protocol_context.load_labware(
1818
"opentrons_flex_96_tiprack_50ul", "D1"
1919
)
@@ -24,10 +24,7 @@ def test_liquid_class_creation_and_property_fetching(
2424

2525
# TODO (spp, 2024-10-17): update this to fetch pipette load name from instrument context
2626
assert (
27-
water.get_for(
28-
pipette_load_name, tiprack.uri
29-
).dispense.flow_rate_by_volume.get_for_volume(1)
30-
== 50
27+
water.get_for(p50, tiprack).dispense.flow_rate_by_volume.get_for_volume(1) == 50
3128
)
3229
assert water.get_for(pipette_load_name, tiprack.uri).aspirate.submerge.speed == 100
3330

0 commit comments

Comments
 (0)