opentrons 8.6.0a12__py3-none-any.whl → 8.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of opentrons might be problematic. Click here for more details.

Files changed (38) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/drivers/asyncio/communication/serial_connection.py +8 -5
  3. opentrons/drivers/flex_stacker/driver.py +6 -1
  4. opentrons/hardware_control/backends/flex_protocol.py +1 -0
  5. opentrons/hardware_control/backends/ot3controller.py +25 -13
  6. opentrons/hardware_control/backends/ot3simulator.py +2 -1
  7. opentrons/hardware_control/dev_types.py +3 -1
  8. opentrons/hardware_control/instruments/ot2/pipette_handler.py +1 -0
  9. opentrons/hardware_control/instruments/ot3/pipette_handler.py +1 -0
  10. opentrons/hardware_control/ot3api.py +3 -1
  11. opentrons/hardware_control/protocols/gripper_controller.py +1 -0
  12. opentrons/protocol_api/core/engine/_default_liquid_class_versions.py +56 -0
  13. opentrons/protocol_api/core/engine/instrument.py +143 -18
  14. opentrons/protocol_api/core/engine/pipette_movement_conflict.py +77 -17
  15. opentrons/protocol_api/core/engine/protocol.py +53 -7
  16. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +1 -1
  17. opentrons/protocol_api/core/protocol.py +1 -1
  18. opentrons/protocol_api/labware.py +36 -2
  19. opentrons/protocol_api/module_contexts.py +146 -14
  20. opentrons/protocol_api/protocol_context.py +162 -12
  21. opentrons/protocol_api/validation.py +4 -0
  22. opentrons/protocol_engine/commands/command_unions.py +2 -0
  23. opentrons/protocol_engine/commands/flex_stacker/common.py +13 -0
  24. opentrons/protocol_engine/commands/flex_stacker/store.py +20 -2
  25. opentrons/protocol_engine/execution/labware_movement.py +14 -12
  26. opentrons/protocol_engine/resources/pipette_data_provider.py +3 -0
  27. opentrons/protocol_engine/state/geometry.py +33 -5
  28. opentrons/protocol_engine/state/labware.py +66 -0
  29. opentrons/protocol_engine/state/modules.py +6 -0
  30. opentrons/protocol_engine/state/pipettes.py +12 -3
  31. opentrons/protocol_engine/types/__init__.py +2 -0
  32. opentrons/protocol_engine/types/labware.py +9 -0
  33. opentrons/protocols/api_support/definitions.py +1 -1
  34. {opentrons-8.6.0a12.dist-info → opentrons-8.7.0.dist-info}/METADATA +4 -4
  35. {opentrons-8.6.0a12.dist-info → opentrons-8.7.0.dist-info}/RECORD +38 -37
  36. {opentrons-8.6.0a12.dist-info → opentrons-8.7.0.dist-info}/WHEEL +0 -0
  37. {opentrons-8.6.0a12.dist-info → opentrons-8.7.0.dist-info}/entry_points.txt +0 -0
  38. {opentrons-8.6.0a12.dist-info → opentrons-8.7.0.dist-info}/licenses/LICENSE +0 -0
opentrons/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '8.6.0a12'
32
- __version_tuple__ = version_tuple = (8, 6, 0, 'a12')
31
+ __version__ = version = '8.7.0'
32
+ __version_tuple__ = version_tuple = (8, 7, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -462,7 +462,10 @@ class AsyncResponseSerialConnection(SerialConnection):
462
462
  self._async_error_ack = async_error_ack.lower()
463
463
 
464
464
  async def send_command(
465
- self, command: CommandBuilder, retries: int = 0, timeout: Optional[float] = None
465
+ self,
466
+ command: CommandBuilder,
467
+ retries: int | None = None,
468
+ timeout: float | None = None,
466
469
  ) -> str:
467
470
  """
468
471
  Send a command and return the response.
@@ -478,12 +481,12 @@ class AsyncResponseSerialConnection(SerialConnection):
478
481
  """
479
482
  return await self.send_data(
480
483
  data=command.build(),
481
- retries=retries or self._number_of_retries,
484
+ retries=retries if retries is not None else self._number_of_retries,
482
485
  timeout=timeout,
483
486
  )
484
487
 
485
488
  async def send_data(
486
- self, data: str, retries: int = 0, timeout: Optional[float] = None
489
+ self, data: str, retries: int | None = None, timeout: float | None = None
487
490
  ) -> str:
488
491
  """
489
492
  Send data and return the response.
@@ -501,7 +504,8 @@ class AsyncResponseSerialConnection(SerialConnection):
501
504
  "timeout", timeout
502
505
  ):
503
506
  return await self._send_data(
504
- data=data, retries=retries or self._number_of_retries
507
+ data=data,
508
+ retries=retries if retries is not None else self._number_of_retries,
505
509
  )
506
510
 
507
511
  async def _send_data(self, data: str, retries: int = 0) -> str:
@@ -517,7 +521,6 @@ class AsyncResponseSerialConnection(SerialConnection):
517
521
  Raises: SerialException
518
522
  """
519
523
  data_encode = data.encode()
520
- retries = retries or self._number_of_retries
521
524
 
522
525
  for retry in range(retries + 1):
523
526
  log.debug(f"{self._name}: Write -> {data_encode!r}")
@@ -461,7 +461,12 @@ class FlexStackerDriver(AbstractFlexStackerDriver):
461
461
  command = GCODE.GET_TOF_MEASUREMENT.build_command().add_element(sensor.name)
462
462
  if resend:
463
463
  command.add_element("R")
464
- resp = await self._connection.send_command(command)
464
+
465
+ # Note: We DONT want to auto resend the request if it fails, because the
466
+ # firmware will send the next frame id instead of the current one missed.
467
+ # So lets set `retries=0` so we only send the frame once and we can
468
+ # use the retry mechanism of the `get_tof_histogram` method instead.
469
+ resp = await self._connection.send_command(command, retries=0)
465
470
  return self.parse_get_tof_measurement(resp)
466
471
 
467
472
  async def get_tof_histogram(self, sensor: TOFSensor) -> TOFMeasurementResult:
@@ -451,6 +451,7 @@ class FlexBackend(Protocol):
451
451
  max_allowed_grip_error: float,
452
452
  hard_limit_lower: float,
453
453
  hard_limit_upper: float,
454
+ disable_geometry_grip_check: bool = False,
454
455
  ) -> None:
455
456
  ...
456
457
 
@@ -686,9 +686,9 @@ class OT3Controller(FlexBackend):
686
686
  return (
687
687
  MoveGroupRunner(
688
688
  move_groups=[move_group],
689
- ignore_stalls=True
690
- if not self._feature_flags.stall_detection_enabled
691
- else False,
689
+ ignore_stalls=(
690
+ True if not self._feature_flags.stall_detection_enabled else False
691
+ ),
692
692
  ),
693
693
  False,
694
694
  )
@@ -712,9 +712,9 @@ class OT3Controller(FlexBackend):
712
712
  return (
713
713
  MoveGroupRunner(
714
714
  move_groups=[tip_motor_move_group],
715
- ignore_stalls=True
716
- if not self._feature_flags.stall_detection_enabled
717
- else False,
715
+ ignore_stalls=(
716
+ True if not self._feature_flags.stall_detection_enabled else False
717
+ ),
718
718
  ),
719
719
  True,
720
720
  )
@@ -939,9 +939,9 @@ class OT3Controller(FlexBackend):
939
939
 
940
940
  runner = MoveGroupRunner(
941
941
  move_groups=[move_group],
942
- ignore_stalls=True
943
- if not self._feature_flags.stall_detection_enabled
944
- else False,
942
+ ignore_stalls=(
943
+ True if not self._feature_flags.stall_detection_enabled else False
944
+ ),
945
945
  )
946
946
  try:
947
947
  positions = await runner.run(can_messenger=self._messenger)
@@ -976,9 +976,9 @@ class OT3Controller(FlexBackend):
976
976
  move_group = self._build_tip_action_group(origin, targets)
977
977
  runner = MoveGroupRunner(
978
978
  move_groups=[move_group],
979
- ignore_stalls=True
980
- if not self._feature_flags.stall_detection_enabled
981
- else False,
979
+ ignore_stalls=(
980
+ True if not self._feature_flags.stall_detection_enabled else False
981
+ ),
982
982
  )
983
983
  try:
984
984
  positions = await runner.run(can_messenger=self._messenger)
@@ -1763,6 +1763,7 @@ class OT3Controller(FlexBackend):
1763
1763
  max_allowed_grip_error: float,
1764
1764
  hard_limit_lower: float,
1765
1765
  hard_limit_upper: float,
1766
+ disable_geometry_grip_check: bool = False,
1766
1767
  ) -> None:
1767
1768
  """
1768
1769
  Check if the gripper is at the expected location.
@@ -1777,7 +1778,16 @@ class OT3Controller(FlexBackend):
1777
1778
  expected_grip_width + grip_width_uncertainty_wider
1778
1779
  )
1779
1780
  current_gripper_position = jaw_width
1780
- if isclose(current_gripper_position, hard_limit_lower):
1781
+ log.info(
1782
+ f"Checking gripper position: current {jaw_width}; max error {max_allowed_grip_error}; hard limits {hard_limit_lower}, {hard_limit_upper}; expected {expected_gripper_position_min}, {expected_grip_width}, {expected_gripper_position_max}; uncertainty {grip_width_uncertainty_narrower}, {grip_width_uncertainty_wider}"
1783
+ )
1784
+ if (
1785
+ isclose(current_gripper_position, hard_limit_lower)
1786
+ # this odd check handles internal backlash that can lead the position to read as if
1787
+ # the gripper has overshot its lower bound; this is physically impossible and an
1788
+ # artifact of the gearing, so it always indicates a hard stop
1789
+ or current_gripper_position < hard_limit_lower
1790
+ ):
1781
1791
  raise FailedGripperPickupError(
1782
1792
  message="Failed to grip: jaws all the way closed",
1783
1793
  details={
@@ -1796,6 +1806,7 @@ class OT3Controller(FlexBackend):
1796
1806
  if (
1797
1807
  current_gripper_position - expected_gripper_position_min
1798
1808
  < -max_allowed_grip_error
1809
+ and not disable_geometry_grip_check
1799
1810
  ):
1800
1811
  raise FailedGripperPickupError(
1801
1812
  message="Failed to grip: jaws closed too far",
@@ -1809,6 +1820,7 @@ class OT3Controller(FlexBackend):
1809
1820
  if (
1810
1821
  current_gripper_position - expected_gripper_position_max
1811
1822
  > max_allowed_grip_error
1823
+ and not disable_geometry_grip_check
1812
1824
  ):
1813
1825
  raise FailedGripperPickupError(
1814
1826
  message="Failed to grip: jaws could not close far enough",
@@ -781,7 +781,7 @@ class OT3Simulator(FlexBackend):
781
781
  next_fw_version=1,
782
782
  fw_update_needed=False,
783
783
  current_fw_sha="simulated",
784
- pcba_revision="A1",
784
+ pcba_revision="A1.0",
785
785
  update_state=None,
786
786
  )
787
787
  for axis in self._present_axes
@@ -848,6 +848,7 @@ class OT3Simulator(FlexBackend):
848
848
  max_allowed_grip_error: float,
849
849
  hard_limit_lower: float,
850
850
  hard_limit_upper: float,
851
+ disable_geometry_grip_check: bool = False,
851
852
  ) -> None:
852
853
  # This is a (pretty bad) simulation of the gripper actually gripping something,
853
854
  # but it should work.
@@ -14,8 +14,9 @@ from opentrons_shared_data.pipette.types import (
14
14
  PipetteModel,
15
15
  PipetteName,
16
16
  ChannelCount,
17
+ PipetteTipType,
18
+ LiquidClasses,
17
19
  )
18
- from opentrons_shared_data.pipette.types import PipetteTipType
19
20
  from opentrons_shared_data.pipette.pipette_definition import (
20
21
  PipetteConfigurations,
21
22
  SupportedTipsDefinition,
@@ -104,6 +105,7 @@ class PipetteDict(InstrumentDict):
104
105
  plunger_positions: Dict[str, float]
105
106
  shaft_ul_per_mm: float
106
107
  available_sensors: AvailableSensorDefinition
108
+ volume_mode: LiquidClasses # LiquidClasses refer to volume mode in this context
107
109
 
108
110
 
109
111
  class PipetteStateDict(TypedDict):
@@ -267,6 +267,7 @@ class PipetteHandlerProvider(Generic[MountType]):
267
267
  "drop_tip": instr.plunger_positions.drop_tip,
268
268
  }
269
269
  result["shaft_ul_per_mm"] = instr.config.shaft_ul_per_mm
270
+ result["volume_mode"] = instr.liquid_class_name
270
271
  return cast(PipetteDict, result)
271
272
 
272
273
  @property
@@ -294,6 +294,7 @@ class OT3PipetteHandler:
294
294
  }
295
295
  result["shaft_ul_per_mm"] = instr.config.shaft_ul_per_mm
296
296
  result["available_sensors"] = instr.config.available_sensors
297
+ result["volume_mode"] = instr.liquid_class_name
297
298
  return cast(PipetteDict, result)
298
299
 
299
300
  @property
@@ -1462,6 +1462,7 @@ class OT3API(
1462
1462
  expected_grip_width: float,
1463
1463
  grip_width_uncertainty_wider: float,
1464
1464
  grip_width_uncertainty_narrower: float,
1465
+ disable_geometry_grip_check: bool = False,
1465
1466
  ) -> None:
1466
1467
  """Ensure that a gripper pickup succeeded.
1467
1468
 
@@ -1480,8 +1481,9 @@ class OT3API(
1480
1481
  grip_width_uncertainty_narrower,
1481
1482
  gripper.jaw_width,
1482
1483
  gripper.max_allowed_grip_error,
1483
- gripper.max_jaw_width,
1484
1484
  gripper.min_jaw_width,
1485
+ gripper.max_jaw_width,
1486
+ disable_geometry_grip_check,
1485
1487
  )
1486
1488
 
1487
1489
  def gripper_jaw_can_home(self) -> bool:
@@ -41,6 +41,7 @@ class GripperController(Protocol):
41
41
  expected_grip_width: float,
42
42
  grip_width_uncertainty_wider: float,
43
43
  grip_width_uncertainty_narrower: float,
44
+ disable_geometry_grip_check: bool = False,
44
45
  ) -> None:
45
46
  """Ensure that a gripper pickup succeeded."""
46
47
 
@@ -0,0 +1,56 @@
1
+ """The versions of standard liquid classes that the Protocol API should load by default."""
2
+
3
+ from typing import TypeAlias
4
+ from opentrons.protocols.api_support.types import APIVersion
5
+
6
+
7
+ DefaultLiquidClassVersions: TypeAlias = dict[APIVersion, dict[str, int]]
8
+
9
+
10
+ # This:
11
+ #
12
+ # {
13
+ # APIVersion(2, 100): {
14
+ # "foo_liquid": 3,
15
+ # },
16
+ # APIVersion(2, 105): {
17
+ # "foo_liquid": 7
18
+ # }
19
+ # }
20
+ #
21
+ # Means this:
22
+ #
23
+ # apiLevels name Default liquid class version
24
+ # ---------------------------------------------------------------
25
+ # <2.100 foo_liquid 1
26
+ # >=2.100,<2.105 foo_liquid 3
27
+ # >=2.105 foo_liquid 7
28
+ # [any] [anything else] 1
29
+ DEFAULT_LIQUID_CLASS_VERSIONS: DefaultLiquidClassVersions = {
30
+ APIVersion(2, 26): {
31
+ "ethanol_80": 2,
32
+ "glycerol_50": 2,
33
+ "water": 2,
34
+ },
35
+ }
36
+
37
+
38
+ def get_liquid_class_version(
39
+ api_version: APIVersion,
40
+ liquid_class_name: str,
41
+ ) -> int:
42
+ """Return what version of a liquid class the Protocol API should load by default."""
43
+ default_lc_versions_newest_to_oldest = sorted(
44
+ DEFAULT_LIQUID_CLASS_VERSIONS.items(), key=lambda kv: kv[0], reverse=True
45
+ )
46
+ for (
47
+ breakpoint_api_version,
48
+ breakpoint_liquid_class_versions,
49
+ ) in default_lc_versions_newest_to_oldest:
50
+ if (
51
+ api_version >= breakpoint_api_version
52
+ and liquid_class_name in breakpoint_liquid_class_versions
53
+ ):
54
+ return breakpoint_liquid_class_versions[liquid_class_name]
55
+
56
+ return 1
@@ -66,7 +66,6 @@ from opentrons.protocol_engine.types.automatic_tip_selection import (
66
66
  )
67
67
  from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError
68
68
  from opentrons.protocol_engine.clients import SyncClient as EngineClient
69
- from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION
70
69
  from opentrons_shared_data.pipette.types import (
71
70
  PIPETTE_API_NAMES_MAP,
72
71
  LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP,
@@ -101,6 +100,9 @@ _RESIN_TIP_DEFAULT_FLOW_RATE = 10.0
101
100
  _FLEX_PIPETTE_NAMES_FIXED_IN = APIVersion(2, 23)
102
101
  """The version after which InstrumentContext.name returns the correct API-specific names of Flex pipettes."""
103
102
 
103
+ _DEFAULT_FLOW_RATE_BUG_FIXED_IN = APIVersion(2, 26)
104
+ """The version after which default flow rates correctly update when pipette tip or volume changes."""
105
+
104
106
 
105
107
  class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
106
108
  """Instrument API core using a ProtocolEngine.
@@ -122,18 +124,27 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
122
124
  self._sync_hardware_api = sync_hardware_api
123
125
  self._protocol_core = protocol_core
124
126
 
125
- # TODO(jbl 2022-11-03) flow_rates should not live in the cores, and should be moved to the protocol context
126
- # along with other rate related refactors (for the hardware API)
127
- flow_rates = self._engine_client.state.pipettes.get_flow_rates(pipette_id)
128
- self._aspirate_flow_rate = find_value_for_api_version(
129
- MAX_SUPPORTED_VERSION, flow_rates.default_aspirate
130
- )
131
- self._dispense_flow_rate = find_value_for_api_version(
132
- MAX_SUPPORTED_VERSION, flow_rates.default_dispense
133
- )
134
- self._blow_out_flow_rate = find_value_for_api_version(
135
- MAX_SUPPORTED_VERSION, flow_rates.default_blow_out
127
+ self._initial_default_flow_rates = (
128
+ self._engine_client.state.pipettes.get_flow_rates(pipette_id)
136
129
  )
130
+ self._user_aspirate_flow_rate: Optional[float] = None
131
+ self._user_dispense_flow_rate: Optional[float] = None
132
+ self._user_blow_out_flow_rate: Optional[float] = None
133
+
134
+ if self._protocol_core.api_version < _DEFAULT_FLOW_RATE_BUG_FIXED_IN:
135
+ # Set to the initial defaults to preserve buggy behavior where the default was not correctly updated
136
+ self._user_aspirate_flow_rate = find_value_for_api_version(
137
+ self._protocol_core.api_version,
138
+ self._initial_default_flow_rates.default_aspirate,
139
+ )
140
+ self._user_dispense_flow_rate = find_value_for_api_version(
141
+ self._protocol_core.api_version,
142
+ self._initial_default_flow_rates.default_dispense,
143
+ )
144
+ self._user_blow_out_flow_rate = find_value_for_api_version(
145
+ self._protocol_core.api_version,
146
+ self._initial_default_flow_rates.default_blow_out,
147
+ )
137
148
  self._flow_rates = FlowRates(self)
138
149
 
139
150
  self.set_default_speed(speed=default_movement_speed)
@@ -1031,13 +1042,64 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1031
1042
  return self._flow_rates
1032
1043
 
1033
1044
  def get_aspirate_flow_rate(self, rate: float = 1.0) -> float:
1034
- return self._aspirate_flow_rate * rate
1045
+ """Returns the user-set aspirate flow rate if that's been modified, otherwise return the default.
1046
+
1047
+ Note that in API versions 2.25 and below `_user_aspirate_flow_rate` will automatically be set to the initial
1048
+ default flow rate when the pipette is loaded (which is the same as the max tip capacity). This is to preserve
1049
+ buggy behavior in which the default was never correctly updated when the pipette picked up or dropped a tip or
1050
+ had its volume configuration changed.
1051
+ """
1052
+ aspirate_flow_rate = (
1053
+ self._user_aspirate_flow_rate
1054
+ or find_value_for_api_version(
1055
+ self._protocol_core.api_version,
1056
+ self._engine_client.state.pipettes.get_flow_rates(
1057
+ self._pipette_id
1058
+ ).default_aspirate,
1059
+ )
1060
+ )
1061
+
1062
+ return aspirate_flow_rate * rate
1035
1063
 
1036
1064
  def get_dispense_flow_rate(self, rate: float = 1.0) -> float:
1037
- return self._dispense_flow_rate * rate
1065
+ """Returns the user-set dispense flow rate if that's been modified, otherwise return the default.
1066
+
1067
+ Note that in API versions 2.25 and below `_user_dispense_flow_rate` will automatically be set to the initial
1068
+ default flow rate when the pipette is loaded (which is the same as the max tip capacity). This is to preserve
1069
+ buggy behavior in which the default was never correctly updated when the pipette picked up or dropped a tip or
1070
+ had its volume configuration changed.
1071
+ """
1072
+ dispense_flow_rate = (
1073
+ self._user_dispense_flow_rate
1074
+ or find_value_for_api_version(
1075
+ self._protocol_core.api_version,
1076
+ self._engine_client.state.pipettes.get_flow_rates(
1077
+ self._pipette_id
1078
+ ).default_dispense,
1079
+ )
1080
+ )
1081
+
1082
+ return dispense_flow_rate * rate
1038
1083
 
1039
1084
  def get_blow_out_flow_rate(self, rate: float = 1.0) -> float:
1040
- return self._blow_out_flow_rate * rate
1085
+ """Returns the user-set blow-out flow rate if that's been modified, otherwise return the default.
1086
+
1087
+ Note that in API versions 2.25 and below `_user_dispense_flow_rate` will automatically be set to the initial
1088
+ default flow rate when the pipette is loaded (which is the same as the max tip capacity). This is to preserve
1089
+ buggy behavior in which the default was never correctly updated when the pipette picked up or dropped a tip or
1090
+ had its volume configuration changed.
1091
+ """
1092
+ blow_out_flow_rate = (
1093
+ self._user_blow_out_flow_rate
1094
+ or find_value_for_api_version(
1095
+ self._protocol_core.api_version,
1096
+ self._engine_client.state.pipettes.get_flow_rates(
1097
+ self._pipette_id
1098
+ ).default_blow_out,
1099
+ )
1100
+ )
1101
+
1102
+ return blow_out_flow_rate * rate
1041
1103
 
1042
1104
  def get_nozzle_configuration(self) -> NozzleConfigurationType:
1043
1105
  return self._engine_client.state.pipettes.get_nozzle_layout_type(
@@ -1084,13 +1146,13 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1084
1146
  ) -> None:
1085
1147
  if aspirate is not None:
1086
1148
  assert aspirate > 0
1087
- self._aspirate_flow_rate = aspirate
1149
+ self._user_aspirate_flow_rate = aspirate
1088
1150
  if dispense is not None:
1089
1151
  assert dispense > 0
1090
- self._dispense_flow_rate = dispense
1152
+ self._user_dispense_flow_rate = dispense
1091
1153
  if blow_out is not None:
1092
1154
  assert blow_out > 0
1093
- self._blow_out_flow_rate = blow_out
1155
+ self._user_blow_out_flow_rate = blow_out
1094
1156
 
1095
1157
  def set_liquid_presence_detection(self, enable: bool) -> None:
1096
1158
  self._liquid_presence_detection = enable
@@ -1105,6 +1167,10 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1105
1167
  ),
1106
1168
  )
1107
1169
  )
1170
+ if self._protocol_core.api_version >= _DEFAULT_FLOW_RATE_BUG_FIXED_IN:
1171
+ self._user_aspirate_flow_rate = None
1172
+ self._user_dispense_flow_rate = None
1173
+ self._user_blow_out_flow_rate = None
1108
1174
 
1109
1175
  def prepare_to_aspirate(self) -> None:
1110
1176
  self._engine_client.execute_command(
@@ -1273,6 +1339,13 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1273
1339
  tiprack_uri=tiprack_uri_for_transfer_props,
1274
1340
  )
1275
1341
 
1342
+ original_aspirate_flow_rate = self._user_aspirate_flow_rate
1343
+ original_dispense_flow_rate = self._user_dispense_flow_rate
1344
+ original_blow_out_flow_rate = self._user_blow_out_flow_rate
1345
+ in_low_volume_mode = self._engine_client.state.pipettes.get_is_low_volume_mode(
1346
+ self._pipette_id
1347
+ )
1348
+
1276
1349
  target_destinations: Sequence[
1277
1350
  Union[Tuple[Location, WellCore], TrashBin, WasteChute]
1278
1351
  ]
@@ -1367,6 +1440,14 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1367
1440
  if not keep_last_tip:
1368
1441
  self._drop_tip_for_liquid_class(trash_location, return_tip)
1369
1442
 
1443
+ if self._protocol_core.api_version >= _DEFAULT_FLOW_RATE_BUG_FIXED_IN:
1444
+ self._restore_pipette_flow_rates_and_volume_mode(
1445
+ aspirate_flow_rate=original_aspirate_flow_rate,
1446
+ dispense_flow_rate=original_dispense_flow_rate,
1447
+ blow_out_flow_rate=original_blow_out_flow_rate,
1448
+ is_low_volume=in_low_volume_mode,
1449
+ )
1450
+
1370
1451
  def distribute_with_liquid_class( # noqa: C901
1371
1452
  self,
1372
1453
  liquid_class: LiquidClass,
@@ -1474,6 +1555,13 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1474
1555
  tiprack_uri=tiprack_uri_for_transfer_props,
1475
1556
  )
1476
1557
 
1558
+ original_aspirate_flow_rate = self._user_aspirate_flow_rate
1559
+ original_dispense_flow_rate = self._user_dispense_flow_rate
1560
+ original_blow_out_flow_rate = self._user_blow_out_flow_rate
1561
+ in_low_volume_mode = self._engine_client.state.pipettes.get_is_low_volume_mode(
1562
+ self._pipette_id
1563
+ )
1564
+
1477
1565
  # This will return a generator that provides pairs of destination well and
1478
1566
  # the volume to dispense into it
1479
1567
  dest_per_volume_step = (
@@ -1617,6 +1705,14 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1617
1705
  if not keep_last_tip:
1618
1706
  self._drop_tip_for_liquid_class(trash_location, return_tip)
1619
1707
 
1708
+ if self._protocol_core.api_version >= _DEFAULT_FLOW_RATE_BUG_FIXED_IN:
1709
+ self._restore_pipette_flow_rates_and_volume_mode(
1710
+ aspirate_flow_rate=original_aspirate_flow_rate,
1711
+ dispense_flow_rate=original_dispense_flow_rate,
1712
+ blow_out_flow_rate=original_blow_out_flow_rate,
1713
+ is_low_volume=in_low_volume_mode,
1714
+ )
1715
+
1620
1716
  def _tip_can_hold_volume_for_multi_dispensing(
1621
1717
  self,
1622
1718
  transfer_volume: float,
@@ -1712,6 +1808,13 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1712
1808
  tiprack_uri=tiprack_uri_for_transfer_props,
1713
1809
  )
1714
1810
 
1811
+ original_aspirate_flow_rate = self._user_aspirate_flow_rate
1812
+ original_dispense_flow_rate = self._user_dispense_flow_rate
1813
+ original_blow_out_flow_rate = self._user_blow_out_flow_rate
1814
+ in_low_volume_mode = self._engine_client.state.pipettes.get_is_low_volume_mode(
1815
+ self._pipette_id
1816
+ )
1817
+
1715
1818
  working_volume = self.get_working_volume_for_tip_rack(tip_racks[0][1])
1716
1819
 
1717
1820
  source_per_volume_step = (
@@ -1796,6 +1899,14 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1796
1899
  if not keep_last_tip:
1797
1900
  self._drop_tip_for_liquid_class(trash_location, return_tip)
1798
1901
 
1902
+ if self._protocol_core.api_version >= _DEFAULT_FLOW_RATE_BUG_FIXED_IN:
1903
+ self._restore_pipette_flow_rates_and_volume_mode(
1904
+ aspirate_flow_rate=original_aspirate_flow_rate,
1905
+ dispense_flow_rate=original_dispense_flow_rate,
1906
+ blow_out_flow_rate=original_blow_out_flow_rate,
1907
+ is_low_volume=in_low_volume_mode,
1908
+ )
1909
+
1799
1910
  def _get_location_and_well_core_from_next_tip_info(
1800
1911
  self,
1801
1912
  tip_info: NextTipInfo,
@@ -1904,6 +2015,20 @@ class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]):
1904
2015
  alternate_drop_location=True,
1905
2016
  )
1906
2017
 
2018
+ def _restore_pipette_flow_rates_and_volume_mode(
2019
+ self,
2020
+ aspirate_flow_rate: Optional[float],
2021
+ dispense_flow_rate: Optional[float],
2022
+ blow_out_flow_rate: Optional[float],
2023
+ is_low_volume: bool,
2024
+ ) -> None:
2025
+ # TODO(jbl 2025-09-17) this works for p50 low volume mode but is not guaranteed to work for future low volume
2026
+ # modes, this should be replaced with something less flaky
2027
+ self.configure_for_volume(self.get_max_volume() if not is_low_volume else 1)
2028
+ self._user_aspirate_flow_rate = aspirate_flow_rate
2029
+ self._user_dispense_flow_rate = dispense_flow_rate
2030
+ self._user_blow_out_flow_rate = blow_out_flow_rate
2031
+
1907
2032
  def aspirate_liquid_class(
1908
2033
  self,
1909
2034
  volume: float,