Skip to content

Commit f46defa

Browse files
feat(api): dynamic liquid tracking hw control changes (#17712)
1 parent e792062 commit f46defa

File tree

5 files changed

+254
-1
lines changed

5 files changed

+254
-1
lines changed

api/src/opentrons/hardware_control/motion_utilities.py

+20
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,26 @@ def target_position_from_plunger(
192192
return all_axes_pos
193193

194194

195+
def target_positions_from_plunger_tracking(
196+
mount: Union[Mount, OT3Mount],
197+
plunger_delta: float,
198+
z_delta: float,
199+
current_position: Dict[Axis, float],
200+
) -> "OrderedDict[Axis, float]":
201+
"""Create a target position for machine axes including plungers for dynamic liquid tracking.
202+
203+
The x/y axes remain constant but the plunger and Z move to create a tracking action.
204+
205+
plunger_delta: the distance the plunger should move- should be determined based on desired
206+
volume to aspirate/dispense.
207+
z_delta: the distance to move the z axis- should be determined based on volume and well geometry.
208+
"""
209+
all_axes_pos = target_position_from_plunger(mount, plunger_delta, current_position)
210+
z_ax = Axis.by_mount(mount)
211+
all_axes_pos[z_ax] = current_position[z_ax] + z_delta
212+
return all_axes_pos
213+
214+
195215
def deck_point_from_machine_point(
196216
machine_point: Point, attitude: AttitudeMatrix, offset: Point
197217
) -> Point:

api/src/opentrons/hardware_control/ot3api.py

+97
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
target_position_from_absolute,
128128
target_position_from_relative,
129129
target_position_from_plunger,
130+
target_positions_from_plunger_tracking,
130131
offset_for_mount,
131132
deck_from_machine,
132133
machine_from_deck,
@@ -2994,6 +2995,102 @@ async def capacitive_sweep(
29942995

29952996
AMKey = TypeVar("AMKey")
29962997

2998+
async def aspirate_while_tracking(
2999+
self,
3000+
mount: Union[top_types.Mount, OT3Mount],
3001+
z_distance: float,
3002+
volume: float,
3003+
flow_rate: float = 1.0,
3004+
) -> None:
3005+
"""
3006+
Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
3007+
3008+
:param mount: A robot mount that the instrument is on.
3009+
:param z_distance: The distance the z axis will move during apsiration.
3010+
:param volume: The volume of liquid to be aspirated.
3011+
:param flow_rate: The flow rate to aspirate with.
3012+
"""
3013+
realmount = OT3Mount.from_mount(mount)
3014+
aspirate_spec = self._pipette_handler.plan_check_aspirate(
3015+
realmount, volume, flow_rate
3016+
)
3017+
if not aspirate_spec:
3018+
return
3019+
target_pos = target_positions_from_plunger_tracking(
3020+
realmount,
3021+
aspirate_spec.plunger_distance,
3022+
z_distance,
3023+
self._current_position,
3024+
)
3025+
try:
3026+
await self._backend.set_active_current(
3027+
{aspirate_spec.axis: aspirate_spec.current}
3028+
)
3029+
async with self.restore_system_constrants():
3030+
await self.set_system_constraints_for_plunger_acceleration(
3031+
realmount, aspirate_spec.acceleration
3032+
)
3033+
await self._move(
3034+
target_pos,
3035+
speed=aspirate_spec.speed,
3036+
home_flagged_axes=False,
3037+
)
3038+
except Exception:
3039+
self._log.exception("Aspirate failed")
3040+
aspirate_spec.instr.set_current_volume(0)
3041+
raise
3042+
else:
3043+
aspirate_spec.instr.add_current_volume(aspirate_spec.volume)
3044+
3045+
async def dispense_while_tracking(
3046+
self,
3047+
mount: Union[top_types.Mount, OT3Mount],
3048+
z_distance: float,
3049+
volume: float,
3050+
push_out: Optional[float],
3051+
flow_rate: float = 1.0,
3052+
) -> None:
3053+
"""
3054+
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
3055+
3056+
:param mount: A robot mount that the instrument is on.
3057+
:param z_distance: The distance the z axis will move during dispensing.
3058+
:param volume: The volume of liquid to be dispensed.
3059+
:param flow_rate: The flow rate to dispense with.
3060+
"""
3061+
realmount = OT3Mount.from_mount(mount)
3062+
dispense_spec = self._pipette_handler.plan_check_dispense(
3063+
realmount, volume, flow_rate, push_out
3064+
)
3065+
if not dispense_spec:
3066+
return
3067+
target_pos = target_positions_from_plunger_tracking(
3068+
realmount,
3069+
dispense_spec.plunger_distance,
3070+
z_distance,
3071+
self._current_position,
3072+
)
3073+
3074+
try:
3075+
await self._backend.set_active_current(
3076+
{dispense_spec.axis: dispense_spec.current}
3077+
)
3078+
async with self.restore_system_constrants():
3079+
await self.set_system_constraints_for_plunger_acceleration(
3080+
realmount, dispense_spec.acceleration
3081+
)
3082+
await self._move(
3083+
target_pos,
3084+
speed=dispense_spec.speed,
3085+
home_flagged_axes=False,
3086+
)
3087+
except Exception:
3088+
self._log.exception("dispense failed")
3089+
dispense_spec.instr.set_current_volume(0)
3090+
raise
3091+
else:
3092+
dispense_spec.instr.remove_current_volume(dispense_spec.volume)
3093+
29973094
@property
29983095
def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]:
29993096
"""Get a view of the state of the currently-attached subsystems."""

api/src/opentrons/hardware_control/protocols/liquid_handler.py

+35
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ async def aspirate(
122122
"""
123123
...
124124

125+
async def aspirate_while_tracking(
126+
self,
127+
mount: MountArgType,
128+
z_distance: float,
129+
volume: float,
130+
flow_rate: float = 1.0,
131+
) -> None:
132+
"""
133+
Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
134+
135+
:param mount: A robot mount that the instrument is on.
136+
:param z_distance: The distance the z axis will move during apsiration.
137+
:param volume: The volume of liquid to be aspirated.
138+
:param flow_rate: The flow rate to aspirate with.
139+
"""
140+
...
141+
125142
async def dispense(
126143
self,
127144
mount: MountArgType,
@@ -143,6 +160,24 @@ async def dispense(
143160
"""
144161
...
145162

163+
async def dispense_while_tracking(
164+
self,
165+
mount: MountArgType,
166+
z_distance: float,
167+
volume: float,
168+
push_out: Optional[float],
169+
flow_rate: float = 1.0,
170+
) -> None:
171+
"""
172+
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
173+
174+
:param mount: A robot mount that the instrument is on.
175+
:param z_distance: The distance the z axis will move during dispensing.
176+
:param volume: The volume of liquid to be dispensed.
177+
:param flow_rate: The flow rate to dispense with.
178+
"""
179+
...
180+
146181
async def blow_out(
147182
self, mount: MountArgType, volume: Optional[float] = None
148183
) -> None:

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async def liquid_probe_in_place(
100100

101101

102102
class HardwarePipettingHandler(PipettingHandler):
103-
"""Liquid handling, using the Hardware API.""" ""
103+
"""Liquid handling, using the Hardware API."""
104104

105105
def __init__(self, state_view: StateView, hardware_api: HardwareControlAPI) -> None:
106106
"""Initialize a PipettingHandler instance."""

api/tests/opentrons/hardware_control/test_ot3_api.py

+101
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
CommandPreconditionViolated,
7676
CommandParameterLimitViolated,
7777
PipetteLiquidNotFoundError,
78+
UnexpectedTipRemovalError,
7879
)
7980
from opentrons_shared_data.gripper.gripper_definition import GripperModel
8081
from opentrons_shared_data.pipette.types import (
@@ -1788,6 +1789,106 @@ async def test_plunger_ready_to_aspirate_after_dispense(
17881789
)
17891790

17901791

1792+
@pytest.mark.parametrize("is_ready", [True, False])
1793+
@pytest.mark.parametrize("tip_present", [True, False])
1794+
async def test_aspirate_while_tracking(
1795+
ot3_hardware: ThreadManager[OT3API],
1796+
mock_move: AsyncMock,
1797+
is_ready: bool,
1798+
tip_present: bool,
1799+
) -> None:
1800+
mount = OT3Mount.LEFT
1801+
1802+
instr_data = AttachedPipette(
1803+
config=load_pipette_data.load_definition(
1804+
PipetteModelType("p1000"),
1805+
PipetteChannelType(1),
1806+
PipetteVersionType(3, 4),
1807+
PipetteOEMType.OT,
1808+
),
1809+
id="fakepip",
1810+
)
1811+
await ot3_hardware.cache_pipette(OT3Mount.LEFT, instr_data, None)
1812+
pipette = ot3_hardware.hardware_pipettes[OT3Mount.LEFT.to_mount()]
1813+
assert pipette
1814+
1815+
if tip_present:
1816+
ot3_hardware.add_tip(mount, 100)
1817+
if is_ready:
1818+
await ot3_hardware.prepare_for_aspirate(OT3Mount.LEFT)
1819+
1820+
if not tip_present:
1821+
with pytest.raises(UnexpectedTipRemovalError):
1822+
await ot3_hardware.aspirate_while_tracking(mount, 8.0, 80.0)
1823+
elif not is_ready:
1824+
with pytest.raises(RuntimeError):
1825+
await ot3_hardware.aspirate_while_tracking(mount, 8.0, 80.0)
1826+
else:
1827+
await ot3_hardware.aspirate_while_tracking(mount, 8.0, 80.0)
1828+
# make sure the move planning math stays the same
1829+
expected_target_pos = {
1830+
Axis.X: 477.2,
1831+
Axis.Y: 493.8,
1832+
Axis.Z_L: 261.475,
1833+
Axis.P_L: 65.983786,
1834+
}
1835+
expected_speed = 46.504982
1836+
called_target_pos = mock_move.call_args_list[-1][0][0]
1837+
called_speed = mock_move.call_args_list[-1][1]["speed"]
1838+
assert expected_target_pos == called_target_pos
1839+
assert expected_speed == called_speed
1840+
1841+
1842+
@pytest.mark.parametrize("tip_present", [True, False])
1843+
@pytest.mark.parametrize("is_ready", [True, False])
1844+
async def test_dispense_while_tracking(
1845+
ot3_hardware: ThreadManager[OT3API],
1846+
mock_move: AsyncMock,
1847+
tip_present: bool,
1848+
is_ready: bool,
1849+
) -> None:
1850+
mount = OT3Mount.LEFT
1851+
1852+
instr_data = AttachedPipette(
1853+
config=load_pipette_data.load_definition(
1854+
PipetteModelType("p1000"),
1855+
PipetteChannelType(1),
1856+
PipetteVersionType(3, 4),
1857+
PipetteOEMType.OT,
1858+
),
1859+
id="fakepip",
1860+
)
1861+
await ot3_hardware.cache_pipette(OT3Mount.LEFT, instr_data, None)
1862+
pipette = ot3_hardware.hardware_pipettes[OT3Mount.LEFT.to_mount()]
1863+
assert pipette
1864+
1865+
if tip_present:
1866+
ot3_hardware.add_tip(mount, 100)
1867+
if is_ready:
1868+
pipette.set_current_volume(80.0)
1869+
1870+
if not tip_present:
1871+
with pytest.raises(UnexpectedTipRemovalError):
1872+
await ot3_hardware.dispense_while_tracking(mount, 8.0, 80.0, push_out=None)
1873+
else:
1874+
await ot3_hardware.dispense_while_tracking(mount, 8.0, 80.0, push_out=None)
1875+
if is_ready:
1876+
# make sure the move planning math stays the same
1877+
expected_target_pos = {
1878+
Axis.X: 477.2,
1879+
Axis.Y: 493.8,
1880+
Axis.Z_L: 261.475,
1881+
Axis.P_L: 72.75754527162978,
1882+
}
1883+
expected_speed = 46.504982
1884+
called_target_pos = mock_move.call_args_list[-1][0][0]
1885+
called_speed = mock_move.call_args_list[-1][1]["speed"]
1886+
assert expected_target_pos == called_target_pos
1887+
assert expected_speed == called_speed
1888+
else:
1889+
assert len(mock_move.call_args_list) == 0
1890+
1891+
17911892
async def test_move_to_plunger_bottom(
17921893
ot3_hardware: ThreadManager[OT3API],
17931894
mock_move: AsyncMock,

0 commit comments

Comments
 (0)