Skip to content

Commit 3a51a90

Browse files
Also apply validation to the hardware pipette handler.
1 parent 41a5242 commit 3a51a90

File tree

2 files changed

+213
-112
lines changed

2 files changed

+213
-112
lines changed

api/src/opentrons/protocol_engine/execution/pipetting.py

Lines changed: 81 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,19 @@ async def aspirate_in_place(
9191
) -> float:
9292
"""Set flow-rate and aspirate."""
9393
# get mount and config data from state and hardware controller
94+
adjusted_volume = _validate_aspirate_volume(
95+
state_view=self._state_view, pipette_id=pipette_id, aspirate_volume=volume
96+
)
9497
hw_pipette = self._state_view.pipettes.get_hardware_pipette(
9598
pipette_id=pipette_id,
9699
attached_pipettes=self._hardware_api.attached_instruments,
97100
)
98101
with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate):
99-
await self._hardware_api.aspirate(mount=hw_pipette.mount, volume=volume)
102+
await self._hardware_api.aspirate(
103+
mount=hw_pipette.mount, volume=adjusted_volume
104+
)
100105

101-
return volume
106+
return adjusted_volume
102107

103108
async def dispense_in_place(
104109
self,
@@ -108,6 +113,9 @@ async def dispense_in_place(
108113
push_out: Optional[float],
109114
) -> float:
110115
"""Dispense liquid without moving the pipette."""
116+
adjusted_volume = _validate_dispense_volume(
117+
state_view=self._state_view, pipette_id=pipette_id, dispense_volume=volume
118+
)
111119
hw_pipette = self._state_view.pipettes.get_hardware_pipette(
112120
pipette_id=pipette_id,
113121
attached_pipettes=self._hardware_api.attached_instruments,
@@ -119,10 +127,10 @@ async def dispense_in_place(
119127
)
120128
with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate):
121129
await self._hardware_api.dispense(
122-
mount=hw_pipette.mount, volume=volume, push_out=push_out
130+
mount=hw_pipette.mount, volume=adjusted_volume, push_out=push_out
123131
)
124132

125-
return volume
133+
return adjusted_volume
126134

127135
async def blow_out_in_place(
128136
self,
@@ -194,8 +202,8 @@ async def aspirate_in_place(
194202
) -> float:
195203
"""Virtually aspirate (no-op)."""
196204
self._validate_tip_attached(pipette_id=pipette_id, command_name="aspirate")
197-
return self._validate_aspirate_volume(
198-
pipette_id=pipette_id, aspirate_volume=volume
205+
return _validate_aspirate_volume(
206+
state_view=self._state_view, pipette_id=pipette_id, aspirate_volume=volume
199207
)
200208

201209
async def dispense_in_place(
@@ -212,8 +220,8 @@ async def dispense_in_place(
212220
"push out value cannot have a negative value."
213221
)
214222
self._validate_tip_attached(pipette_id=pipette_id, command_name="dispense")
215-
return self._validate_dispense_volume(
216-
pipette_id=pipette_id, dispense_volume=volume
223+
return _validate_dispense_volume(
224+
state_view=self._state_view, pipette_id=pipette_id, dispense_volume=volume
217225
)
218226

219227
async def blow_out_in_place(
@@ -231,57 +239,6 @@ def _validate_tip_attached(self, pipette_id: str, command_name: str) -> None:
231239
f"Cannot perform {command_name} without a tip attached"
232240
)
233241

234-
def _validate_aspirate_volume(
235-
self, pipette_id: str, aspirate_volume: float
236-
) -> float:
237-
"""Get whether the aspirated volume is valid to aspirate."""
238-
working_volume = self._state_view.pipettes.get_working_volume(
239-
pipette_id=pipette_id
240-
)
241-
242-
current_volume = (
243-
self._state_view.pipettes.get_aspirated_volume(pipette_id=pipette_id) or 0
244-
)
245-
246-
available_volume = working_volume - current_volume
247-
available_volume_with_tolerance = (
248-
available_volume + _VOLUME_ROUNDING_ERROR_TOLERANCE
249-
)
250-
251-
if aspirate_volume > available_volume_with_tolerance:
252-
raise InvalidAspirateVolumeError(
253-
attempted_aspirate_volume=aspirate_volume,
254-
available_volume=available_volume,
255-
max_pipette_volume=self._state_view.pipettes.get_maximum_volume(
256-
pipette_id=pipette_id
257-
),
258-
max_tip_volume=self._get_max_tip_volume(pipette_id=pipette_id),
259-
)
260-
else:
261-
return min(aspirate_volume, available_volume)
262-
263-
def _validate_dispense_volume(
264-
self, pipette_id: str, dispense_volume: float
265-
) -> float:
266-
"""Validate dispense volume."""
267-
aspirated_volume = self._state_view.pipettes.get_aspirated_volume(pipette_id)
268-
if aspirated_volume is None:
269-
raise InvalidDispenseVolumeError(
270-
"Cannot perform a dispense if there is no volume in attached tip."
271-
)
272-
else:
273-
remaining = aspirated_volume - dispense_volume
274-
if remaining < -_VOLUME_ROUNDING_ERROR_TOLERANCE:
275-
raise InvalidDispenseVolumeError(
276-
f"Cannot dispense {dispense_volume} µL when only {aspirated_volume} µL has been aspirated."
277-
)
278-
else:
279-
return min(dispense_volume, aspirated_volume)
280-
281-
def _get_max_tip_volume(self, pipette_id: str) -> Optional[float]:
282-
attached_tip = self._state_view.pipettes.get_attached_tip(pipette_id=pipette_id)
283-
return None if attached_tip is None else attached_tip.volume
284-
285242

286243
def create_pipetting_handler(
287244
state_view: StateView, hardware_api: HardwareControlAPI
@@ -292,3 +249,68 @@ def create_pipetting_handler(
292249
if state_view.config.use_virtual_pipettes is False
293250
else VirtualPipettingHandler(state_view=state_view)
294251
)
252+
253+
254+
def _validate_aspirate_volume(
255+
state_view: StateView, pipette_id: str, aspirate_volume: float
256+
) -> float:
257+
"""Get whether the given volume is valid to aspirate right now.
258+
259+
Return the volume to aspirate, possibly clamped, or raise an
260+
InvalidAspirateVolumeError.
261+
"""
262+
working_volume = state_view.pipettes.get_working_volume(pipette_id=pipette_id)
263+
264+
current_volume = (
265+
state_view.pipettes.get_aspirated_volume(pipette_id=pipette_id) or 0
266+
)
267+
268+
# TODO(mm, 2024-01-11): We should probably just use
269+
# state_view.pipettes.get_available_volume()? Its whole `None` return vs. exception
270+
# raising thing is confusing me.
271+
available_volume = working_volume - current_volume
272+
available_volume_with_tolerance = (
273+
available_volume + _VOLUME_ROUNDING_ERROR_TOLERANCE
274+
)
275+
276+
if aspirate_volume > available_volume_with_tolerance:
277+
raise InvalidAspirateVolumeError(
278+
attempted_aspirate_volume=aspirate_volume,
279+
available_volume=available_volume,
280+
max_pipette_volume=state_view.pipettes.get_maximum_volume(
281+
pipette_id=pipette_id
282+
),
283+
max_tip_volume=_get_max_tip_volume(
284+
state_view=state_view, pipette_id=pipette_id
285+
),
286+
)
287+
else:
288+
return min(aspirate_volume, available_volume)
289+
290+
291+
def _validate_dispense_volume(
292+
state_view: StateView, pipette_id: str, dispense_volume: float
293+
) -> float:
294+
"""Get whether the given volume is valid to dispense right now.
295+
296+
Return the volume to dispense, possibly clamped, or raise an
297+
InvalidDispenseVolumeError.
298+
"""
299+
aspirated_volume = state_view.pipettes.get_aspirated_volume(pipette_id)
300+
if aspirated_volume is None:
301+
raise InvalidDispenseVolumeError(
302+
"Cannot perform a dispense if there is no volume in attached tip."
303+
)
304+
else:
305+
remaining = aspirated_volume - dispense_volume
306+
if remaining < -_VOLUME_ROUNDING_ERROR_TOLERANCE:
307+
raise InvalidDispenseVolumeError(
308+
f"Cannot dispense {dispense_volume} µL when only {aspirated_volume} µL has been aspirated."
309+
)
310+
else:
311+
return min(dispense_volume, aspirated_volume)
312+
313+
314+
def _get_max_tip_volume(state_view: StateView, pipette_id: str) -> Optional[float]:
315+
attached_tip = state_view.pipettes.get_attached_tip(pipette_id=pipette_id)
316+
return None if attached_tip is None else attached_tip.volume

0 commit comments

Comments
 (0)