Skip to content

Commit 658fbd9

Browse files
authored
fix(api): add checking for imposible dynamic moves (#20054)
# Overview This PR updates the OT3 Controller's motion planning to adjust the commanded speed for a multi axis move if one of the plunger's are in the moving axis. this ensures that the pipettes' plunger will move at the speed commanded. Right now we only move the plunger and other axis during dynamic pipetting so this won't affect anything else in the system. After doing it's best to adjust this speed, if the move is still impossible an error will be logged into the api logs.
1 parent a61512e commit 658fbd9

File tree

4 files changed

+293
-2
lines changed

4 files changed

+293
-2
lines changed

api/src/opentrons/hardware_control/backends/ot3controller.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,12 @@ def _build_move_node_axis_runner(
662662
) -> Tuple[Optional[MoveGroupRunner], bool]:
663663
if not target:
664664
return None, False
665-
move_target = MoveTarget.build(position=target, max_speed=speed)
665+
# Create a target that doesn't incorporate the plunger into a joint axis with the gantry
666+
plunger_axes = [Axis.P_L, Axis.P_R]
667+
move_target = self._move_manager.devectorize_axes(
668+
origin, target, speed, plunger_axes
669+
)
670+
666671
try:
667672
_, movelist = self._move_manager.plan_motion(
668673
origin=origin, target_list=[move_target]
@@ -693,6 +698,20 @@ def _build_move_node_axis_runner(
693698
move_group, ordered_nodes, (delay_nodes, delay_time)
694699
)
695700

701+
(
702+
plunger_slowed,
703+
error_str,
704+
) = self._move_manager.ensure_pipette_flow_rate_unchanged(
705+
[node_to_axis(node) for node in ordered_nodes],
706+
origin,
707+
target,
708+
speed,
709+
move_group,
710+
[(ax, axis_to_node(ax)) for ax in plunger_axes],
711+
)
712+
if plunger_slowed:
713+
log.error(error_str)
714+
696715
return (
697716
MoveGroupRunner(
698717
move_groups=[move_group],

api/tests/opentrons/hardware_control/backends/test_ot3_controller.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,148 @@ async def test_controller_move(
14831483
assert gear_position == gear_position
14841484

14851485

1486+
@pytest.mark.parametrize(
1487+
argnames=["origin_pos", "target_pos", "expected_pos", "gear_position"],
1488+
argvalues=[
1489+
[
1490+
{
1491+
Axis.X: 0,
1492+
Axis.Y: 0,
1493+
Axis.Z_L: 0,
1494+
Axis.Z_R: 0,
1495+
Axis.P_L: 0,
1496+
Axis.P_R: 0,
1497+
Axis.Z_G: 0,
1498+
Axis.G: 0,
1499+
},
1500+
{
1501+
Axis.X: 10,
1502+
Axis.Y: 10,
1503+
Axis.Z_L: 50,
1504+
Axis.P_L: 70,
1505+
},
1506+
{
1507+
Axis.X: 10,
1508+
Axis.Y: 10,
1509+
Axis.Z_L: 50,
1510+
Axis.Z_R: 0,
1511+
Axis.P_L: 70,
1512+
Axis.P_R: 0,
1513+
Axis.Z_G: 0,
1514+
Axis.G: 0,
1515+
},
1516+
None,
1517+
],
1518+
[
1519+
{
1520+
Axis.X: 0,
1521+
Axis.Y: 0,
1522+
Axis.Z_L: 0,
1523+
Axis.Z_R: 0,
1524+
Axis.P_L: 0,
1525+
Axis.P_R: 0,
1526+
Axis.Z_G: 0,
1527+
Axis.G: 0,
1528+
},
1529+
{
1530+
Axis.X: 20,
1531+
Axis.Y: 20,
1532+
Axis.Z_L: 10,
1533+
Axis.P_L: 48,
1534+
},
1535+
{
1536+
Axis.X: 20,
1537+
Axis.Y: 20,
1538+
Axis.Z_L: 10,
1539+
Axis.Z_R: 0,
1540+
Axis.P_L: 48,
1541+
Axis.P_R: 0,
1542+
Axis.Z_G: 0,
1543+
Axis.G: 0,
1544+
},
1545+
None,
1546+
],
1547+
[
1548+
{
1549+
Axis.X: 0,
1550+
Axis.Y: 0,
1551+
Axis.Z_L: 0,
1552+
Axis.Z_R: 0,
1553+
Axis.P_L: 0,
1554+
Axis.P_R: 0,
1555+
Axis.Z_G: 0,
1556+
Axis.G: 0,
1557+
},
1558+
{
1559+
Axis.P_L: 70,
1560+
},
1561+
{
1562+
Axis.X: 0,
1563+
Axis.Y: 0,
1564+
Axis.Z_L: 0,
1565+
Axis.Z_R: 0,
1566+
Axis.P_L: 70,
1567+
Axis.P_R: 0,
1568+
Axis.Z_G: 0,
1569+
Axis.G: 0,
1570+
},
1571+
None,
1572+
],
1573+
[
1574+
{
1575+
Axis.X: 0,
1576+
Axis.Y: 0,
1577+
Axis.Z_L: 0,
1578+
Axis.Z_R: 0,
1579+
Axis.P_L: 0,
1580+
Axis.P_R: 0,
1581+
Axis.Z_G: 0,
1582+
Axis.G: 0,
1583+
},
1584+
{
1585+
Axis.P_L: 30, # Too short to hit top speed
1586+
},
1587+
{
1588+
Axis.X: 0,
1589+
Axis.Y: 0,
1590+
Axis.Z_L: 0,
1591+
Axis.Z_R: 0,
1592+
Axis.P_L: 30,
1593+
Axis.P_R: 0,
1594+
Axis.Z_G: 0,
1595+
Axis.G: 0,
1596+
},
1597+
None,
1598+
],
1599+
],
1600+
)
1601+
async def test_controller_move_dynamic(
1602+
controller: OT3Controller,
1603+
mock_present_devices: mock.AsyncMock,
1604+
origin_pos: Dict[Axis, float],
1605+
target_pos: Dict[Axis, float],
1606+
expected_pos: Dict[Axis, float],
1607+
gear_position: Optional[float],
1608+
) -> None:
1609+
from copy import deepcopy
1610+
1611+
controller.update_constraints_for_gantry_load(GantryLoad.LOW_THROUGHPUT)
1612+
1613+
run_target_pos = deepcopy(target_pos)
1614+
config = {"run.side_effect": move_group_run_side_effect(controller, run_target_pos)}
1615+
with mock.patch( # type: ignore [call-overload]
1616+
"opentrons.hardware_control.backends.ot3controller.MoveGroupRunner",
1617+
spec=MoveGroupRunner,
1618+
**config
1619+
):
1620+
await controller.move(origin_pos, target_pos, 70)
1621+
position = await controller.update_position()
1622+
gear_position = controller.gear_motor_position
1623+
1624+
assert position == expected_pos
1625+
assert gear_position == gear_position
1626+
1627+
14861628
@pytest.mark.parametrize(
14871629
argnames=["axes", "pipette_has_sensor"],
14881630
argvalues=[[[Axis.P_L, Axis.P_R], True], [[Axis.P_L, Axis.P_R], False]],

hardware/opentrons_hardware/hardware_control/motion_planning/move_manager.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Move manager."""
22
import logging
3-
from typing import List, Tuple, Generic
3+
from typing import List, Tuple, Generic, Dict
44
from opentrons_hardware.hardware_control.motion_planning import move_utils
55
from opentrons_hardware.hardware_control.motion_planning.types import (
66
Coordinates,
@@ -10,6 +10,10 @@
1010
AxisKey,
1111
CoordinateValue,
1212
)
13+
from opentrons_hardware.hardware_control.motion import MoveGroup
14+
from opentrons_hardware.firmware_bindings.constants import NodeId
15+
from numpy import isclose
16+
import math
1317

1418
log = logging.getLogger(__name__)
1519

@@ -57,6 +61,77 @@ def _add_dummy_start_end_to_moves(
5761
end_move = Move.build_dummy(move_list[0].unit_vector.keys())
5862
return [start_move] + move_list + [end_move]
5963

64+
def devectorize_axes(
65+
self,
66+
origin: Coordinates[AxisKey, CoordinateValue],
67+
target: Coordinates[AxisKey, CoordinateValue],
68+
speed: float,
69+
axes: List[AxisKey],
70+
) -> MoveTarget[AxisKey]:
71+
"""This helper method is used when a plunger is moving in conjunction with other axis.
72+
73+
It adjusts the speed by multiplying it by the inverse of the plunger's unit vector.
74+
If only the plunger is moving, this will just result in the speed being multiplied by 1
75+
and if there is more axes moving the 'speed' argument will be increased in such a way that
76+
the plunger will have the speed specified by the speed argument once the target is created.
77+
"""
78+
move_target = MoveTarget.build(position=target, max_speed=speed) # type: ignore[type-var]
79+
move_info = self._get_initial_moves_from_targets(origin, [move_target])[1]
80+
target_axes = move_info.unit_vector.keys()
81+
for axis in axes:
82+
if axis in target_axes:
83+
move_target = MoveTarget.build( # type: ignore[type-var]
84+
position=target,
85+
max_speed=float(speed * (1 / move_info.unit_vector[axis].item())),
86+
)
87+
return move_target
88+
89+
def ensure_pipette_flow_rate_unchanged(
90+
self,
91+
moving_axes: List[AxisKey],
92+
origin: Dict[AxisKey, float],
93+
target: Dict[AxisKey, float],
94+
speed: float,
95+
move_group: MoveGroup,
96+
plunger_axes: List[Tuple[AxisKey, NodeId]],
97+
) -> Tuple[bool, str]:
98+
"""This examines the move group created to see if the plunger's speed is being reduced."""
99+
for axis, node in plunger_axes:
100+
if axis in moving_axes:
101+
pip_constraints = self.get_constraints()[axis]
102+
max_speed = pip_constraints.max_speed
103+
check_speed = speed
104+
if speed > max_speed:
105+
# if we've commanded an arbitrarily high speed drop it to the max speed.
106+
check_speed = max_speed.item()
107+
pip_distance = abs(target[axis] - origin[axis])
108+
# check to see if we can actually achieve this speed.
109+
acceleration_time = (
110+
pip_constraints.max_speed - pip_constraints.max_speed_discont
111+
) / pip_constraints.max_acceleration
112+
acceleration_distance = (
113+
pip_constraints.max_speed_discont * acceleration_time
114+
+ 0.5 * pip_constraints.max_acceleration * (acceleration_time**2)
115+
)
116+
if 2 * acceleration_distance > pip_distance:
117+
# distance commanded is too short to achieve commanded speed drop the check speed
118+
# to as fast as it can do in that distance
119+
# V_max = sqrt(2 * Accel * d_accel_phase + discontinuity^2)
120+
check_speed = math.sqrt(
121+
2 * pip_constraints.max_acceleration * pip_distance / 2
122+
+ pip_constraints.max_speed_discont**2
123+
)
124+
pipette_speed = 0.0
125+
# Iterate through the move group and find the top speed.
126+
for step in move_group:
127+
pipette_speed = max(pipette_speed, step[node].velocity_mm_sec.item()) # type: ignore [union-attr]
128+
if not isclose(pipette_speed, check_speed):
129+
return (
130+
True,
131+
f"Slowing down the plunger flow rate, commanded speed {check_speed} actual speed {pipette_speed}",
132+
)
133+
return (False, "")
134+
60135
def plan_motion(
61136
self,
62137
origin: Coordinates[AxisKey, CoordinateValue],

hardware/tests/opentrons_hardware/hardware_control/test_motion_plan.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,58 @@ def test_pipette_high_speed_motion() -> None:
267267
top_set_axis_speed = unit_vector[set_axis_kind] * block.final_speed
268268
if top_set_axis_speed != 0:
269269
assert abs(top_set_axis_speed) == dummy_em_pipette_max_speed
270+
271+
272+
@given(
273+
x_constraint=generate_axis_constraint(),
274+
y_constraint=generate_axis_constraint(),
275+
z_constraint=generate_axis_constraint(),
276+
a_constraint=generate_axis_constraint(),
277+
b_constraint=generate_axis_constraint(),
278+
c_constraint=generate_axis_constraint(),
279+
)
280+
async def test_plunger_devectorize(
281+
x_constraint: AxisConstraints,
282+
y_constraint: AxisConstraints,
283+
z_constraint: AxisConstraints,
284+
a_constraint: AxisConstraints,
285+
b_constraint: AxisConstraints,
286+
c_constraint: AxisConstraints,
287+
) -> None:
288+
"""Test to make sure the devectorize function can isolate an axis from the move target speed limit."""
289+
constraints: SystemConstraints[str] = {
290+
"X": x_constraint,
291+
"Y": y_constraint,
292+
"Z": z_constraint,
293+
"A": a_constraint,
294+
"B": b_constraint,
295+
"C": c_constraint,
296+
}
297+
manager = move_manager.MoveManager(constraints=constraints)
298+
origin = {"X": 0, "Y": 0, "Z": 0, "A": 0}
299+
target = {"X": 10, "Y": 10, "Z": 10, "A": 20}
300+
speed = 10
301+
devectorized = manager.devectorize_axes(origin, target, speed, ["A"])
302+
303+
converged, blend_log = manager.plan_motion(
304+
origin=origin,
305+
target_list=[devectorized],
306+
iteration_limit=20,
307+
)
308+
uv = blend_log[0][0].unit_vector
309+
310+
assert (uv["A"]) * devectorized.max_speed == speed
311+
312+
# make sure that if the axis isn't included it doesn't effect other axes.
313+
target = {"X": 10, "Y": 10, "Z": 10}
314+
speed = 10
315+
devectorized = manager.devectorize_axes(origin, target, speed, ["A"])
316+
317+
converged, blend_log = manager.plan_motion(
318+
origin=origin,
319+
target_list=[devectorized],
320+
iteration_limit=20,
321+
)
322+
uv = blend_log[0][0].unit_vector
323+
324+
assert devectorized.max_speed == speed

0 commit comments

Comments
 (0)