Skip to content

Commit

Permalink
chore(api,hardware): ot3 calibration backend (#10067)
Browse files Browse the repository at this point in the history
The OT3 has an autocalibration routine that relies on its pipettes' (and
gripper's) capacitive sensors. To execute this routine, the
calibration_probe method is used to find the absolute position of the
deck and the absolute position of a slot machined in the deck (with a
binary search for each edge). What comes out of this routine, which is
in new free functions in a helper file, is the estimated position in
deck coordinates of the center of the calibration slot. This can then be
subtracted from the nominal position of the center of the slot to form a
pipette- and mount-specific offset that can be saved as the OT3
equivalent of pipette calibration.

This is a heavily parametrized process; everything from the initial
positions to the amount of binary searching to the exact behavior of
each capacitive sensor pass can be changed. To accomodate this, add (a
significant amount of) content to the robot config.

While adding tests for new configuration values, a little refactoring
reduced the size of the test files and added typing to some of the
config tests.

Tests for the calibration functionality itself are a little tough to
write in a robust way. find_edge has complex behavior between the error
handling and the binary search, so that is well tested; but
find_deck_position strings together a couple extremely straightforward
ot3api calls that would not be testable without writing the same code.

We also need to add a context manager for telling a pipette to change its
sync pin based on sensor values. Since only one sensor can be
bound to an output at a time, it's important for that binding to end
when it's relevant to do so. The best way to do that is a context
manager, which this adds: in
opentrons_hardware.sensors.scheduler.Scheduler, you can now do

async with scheduler.bind_sync(...):

to get an async context manager that binds a sensor and cleans up
afterwards.

This also required extending the rest of the scheduler functionality a
bit more - waiting for a new kind of response - which meant it made
sense to do a slight refactor of the response waiter to properly format
data. There were also some dataclass changes that required adapting
other classes.

That binding can be used in autocalibration, when we move a Z
axis until that tool senses a collision with the capacitive sensor. Now
that there is an easy way to bind a sensor output, add
hardware_control.tool_sensors.capacitive_probe (hardware_control.tool_sensors
will eventually also contain the similar function for the pressure
sensors that will be used for liquid level sensing). capacitive_probe
assumes the system is already slightly above the anticipated position at
which it will hit something, and moves down slightly more while
configuring the proper sensor.

We can also optionally stream out the sensor values, for now just to
log, for debugging and inspection.

* refactor(api): add OT3API.capacitive_probe

This is a hardware control layer implementation of OT3 capacitive
probing, using new capabilities in opentrons_hardware to implement it.
The new function uses a type that will be in the config to configure
itself. It has similar semantics to aspirate() and dispense() - the
gantry will not move and it's assumed something else has put it in the
right place, and so on. The new method will move down until the
capacitive sensor triggers and then return the position at which it
triggered in deck coordinates before returning to a safe height.

Tests for the function only exist on the API layer because the
controller is a oneliner.

api test fixes and format

* refactor(api): add logic for ot3 calibration
  • Loading branch information
sfoster1 authored May 26, 2022
1 parent df2d7ca commit 929ef3f
Show file tree
Hide file tree
Showing 26 changed files with 1,590 additions and 533 deletions.
20 changes: 18 additions & 2 deletions api/mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,28 @@ disallow_incomplete_defs = False
no_implicit_optional = False
warn_return_any = False

# ~85 errors
[mypy-tests.opentrons.config.*]
[mypy-tests.opentrons.config.test_reset]
disallow_untyped_defs = False
disallow_untyped_calls = False
no_implicit_optional = False

[mypy-tests.opentrons.config.test_pipette_config]
disallow_untyped_defs = False
disallow_untyped_calls = False
no_implicit_optional = False

[mypy-tests.opentrons.config.test_advanced_settings]
disallow_untyped_defs = False
disallow_untyped_calls = False
no_implicit_optional = False

[mypy-tests.opentrons.config.test_advanced_settings_migration]
disallow_untyped_defs = False
disallow_untyped_calls = False
no_implicit_optional = False



# ~30 errors
[mypy-tests.opentrons.drivers.*]
disallow_untyped_defs = False
Expand Down
86 changes: 86 additions & 0 deletions api/src/opentrons/config/defaults_ot3.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,35 @@
OT3MotionSettings,
OT3Transform,
Offset,
OT3CalibrationSettings,
CapacitivePassSettings,
ZSenseSettings,
EdgeSenseSettings,
)

DEFAULT_PIPETTE_OFFSET = [0.0, 0.0, 0.0]

DEFAULT_CALIBRATION_SETTINGS: Final[OT3CalibrationSettings] = OT3CalibrationSettings(
z_offset=ZSenseSettings(
point=(209, 170, 0),
pass_settings=CapacitivePassSettings(
prep_distance_mm=3, max_overrun_distance_mm=3, speed_mm_per_s=1
),
),
edge_sense=EdgeSenseSettings(
plus_x_pos=(219, 150, 0),
minus_x_pos=(199, 150, 0),
plus_y_pos=(209, 160, 0),
minus_y_pos=(209, 140, 0),
overrun_tolerance_mm=0.5,
early_sense_tolerance_mm=0.2,
pass_settings=CapacitivePassSettings(
prep_distance_mm=1, max_overrun_distance_mm=1, speed_mm_per_s=1
),
search_initial_tolerance_mm=5.0,
search_iteration_limit=10,
),
)

ROBOT_CONFIG_VERSION: Final = 1
DEFAULT_LOG_LEVEL: Final = "INFO"
Expand Down Expand Up @@ -272,6 +297,64 @@ def _build_default_transform(
return cast(OT3Transform, from_conf)


def _build_default_cap_pass(
from_conf: Any, default: CapacitivePassSettings
) -> CapacitivePassSettings:
return CapacitivePassSettings(
prep_distance_mm=from_conf.get("prep_distance_mm", default.prep_distance_mm),
max_overrun_distance_mm=from_conf.get(
"max_overrun_distance_mm", default.max_overrun_distance_mm
),
speed_mm_per_s=from_conf.get("speed_mm_per_s", default.speed_mm_per_s),
)


def _build_default_z_pass(from_conf: Any, default: ZSenseSettings) -> ZSenseSettings:
return ZSenseSettings(
point=from_conf.get("point", default.point),
pass_settings=_build_default_cap_pass(
from_conf.get("pass_settings", {}), default.pass_settings
),
)


def _build_default_edge_sense(
from_conf: Any, default: EdgeSenseSettings
) -> EdgeSenseSettings:
return EdgeSenseSettings(
plus_x_pos=from_conf.get("plus_x_pos", default.plus_x_pos),
minus_x_pos=from_conf.get("minus_x_pos", default.minus_x_pos),
plus_y_pos=from_conf.get("plus_y_pos", default.plus_y_pos),
minus_y_pos=from_conf.get("minus_y_pos", default.minus_y_pos),
overrun_tolerance_mm=from_conf.get(
"overrun_tolerance_mm", default.overrun_tolerance_mm
),
early_sense_tolerance_mm=from_conf.get(
"early_sense_tolerance_mm", default.early_sense_tolerance_mm
),
pass_settings=_build_default_cap_pass(
from_conf.get("pass_settings", {}), default.pass_settings
),
search_initial_tolerance_mm=from_conf.get(
"search_initial_tolerance_mm", default.search_initial_tolerance_mm
),
search_iteration_limit=from_conf.get(
"search_iteration_limit", default.search_iteration_limit
),
)


def _build_default_calibration(
from_conf: Any, default: OT3CalibrationSettings
) -> OT3CalibrationSettings:
return OT3CalibrationSettings(
z_offset=_build_default_z_pass(from_conf.get("z_offset", {}), default.z_offset),
edge_sense=_build_default_edge_sense(
from_conf.get("edge_sense", {}), default.edge_sense
),
)


def build_with_defaults(robot_settings: Dict[str, Any]) -> OT3Config:
motion_settings = robot_settings.get("motion_settings", {})
current_settings = robot_settings.get("current_settings", {})
Expand Down Expand Up @@ -324,6 +407,9 @@ def build_with_defaults(robot_settings: Dict[str, Any]) -> OT3Config:
gripper_mount_offset=_build_default_offset(
robot_settings.get("gripper_mount_offset", []), DEFAULT_GRIPPER_MOUNT_OFFSET
),
calibration=_build_default_calibration(
robot_settings.get("calibration", {}), DEFAULT_CALIBRATION_SETTINGS
),
)


Expand Down
33 changes: 33 additions & 0 deletions api/src/opentrons/config/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,38 @@ def by_gantry_load(
return base


@dataclass(frozen=True)
class CapacitivePassSettings:
prep_distance_mm: float
max_overrun_distance_mm: float
speed_mm_per_s: float


@dataclass(frozen=True)
class ZSenseSettings:
point: Offset
pass_settings: CapacitivePassSettings


@dataclass(frozen=True)
class EdgeSenseSettings:
plus_x_pos: Offset
minus_x_pos: Offset
plus_y_pos: Offset
minus_y_pos: Offset
overrun_tolerance_mm: float
early_sense_tolerance_mm: float
pass_settings: CapacitivePassSettings
search_initial_tolerance_mm: float
search_iteration_limit: int


@dataclass(frozen=True)
class OT3CalibrationSettings:
z_offset: ZSenseSettings
edge_sense: EdgeSenseSettings


@dataclass
class OT3Config:
model: Literal["OT-3 Standard"]
Expand All @@ -135,3 +167,4 @@ class OT3Config:
left_mount_offset: Offset
right_mount_offset: Offset
gripper_mount_offset: Offset
calibration: OT3CalibrationSettings
16 changes: 16 additions & 0 deletions api/src/opentrons/hardware_control/backends/ot3controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
create_home_group,
node_to_axis,
sub_system_to_node_id,
sensor_node_for_mount,
)

try:
Expand Down Expand Up @@ -78,6 +79,8 @@
from opentrons_hardware.hardware_control.types import NodeMap
from opentrons_hardware.hardware_control.tools import detector, types as ohc_tool_types

from opentrons_hardware.hardware_control.tool_sensors import capacitive_probe

if TYPE_CHECKING:
from opentrons_shared_data.pipette.dev_types import PipetteName, PipetteModel
from ..dev_types import (
Expand Down Expand Up @@ -634,3 +637,16 @@ def _axis_map_to_present_nodes(
) -> NodeMap[MapPayload]:
by_node = {axis_to_node(k): v for k, v in to_xform.items()}
return {k: v for k, v in by_node.items() if k in self._present_nodes}

async def capacitive_probe(
self, mount: OT3Mount, distance_mm: float, speed_mm_per_s: float
) -> None:
pos = await capacitive_probe(
self._messenger,
sensor_node_for_mount(mount),
distance_mm,
speed_mm_per_s,
log_sensor_values=True,
)

self._position[axis_to_node(OT3Axis.by_mount(mount))] = pos
6 changes: 6 additions & 0 deletions api/src/opentrons/hardware_control/backends/ot3simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
create_move_group,
get_current_settings,
node_to_axis,
axis_to_node,
)

from opentrons_hardware.firmware_bindings.constants import NodeId
Expand Down Expand Up @@ -406,3 +407,8 @@ async def probe_network(self) -> None:
if self._attached_instruments[OT3Mount.RIGHT].get("model", None):
nodes.add(NodeId.pipette_right)
self._present_nodes = nodes

async def capacitive_probe(
self, mount: OT3Mount, distance_mm: float, speed_mm_per_s: float
) -> None:
self._position[axis_to_node(OT3Axis.by_mount(mount))] += distance_mm
13 changes: 13 additions & 0 deletions api/src/opentrons/hardware_control/backends/ot3utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
OT3AxisMap,
CurrentConfig,
OT3SubSystem,
OT3Mount,
)
import numpy as np

Expand All @@ -18,6 +19,7 @@
Move,
CoordinateValue,
)
from opentrons_hardware.hardware_control.tool_sensors import ProbeTarget
from opentrons_hardware.hardware_control.motion_planning.move_utils import (
unit_vector_multiplication,
)
Expand Down Expand Up @@ -214,3 +216,14 @@ def axis_convert(
if node_is_axis(node):
ret[node_to_axis(node)] = value
return ret


_sensor_node_lookup: Dict[OT3Mount, ProbeTarget] = {
OT3Mount.LEFT: NodeId.pipette_left,
OT3Mount.RIGHT: NodeId.pipette_right,
OT3Mount.GRIPPER: NodeId.gripper,
}


def sensor_node_for_mount(mount: OT3Mount) -> ProbeTarget:
return _sensor_node_lookup[mount]
4 changes: 3 additions & 1 deletion api/src/opentrons/hardware_control/instrument_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,9 @@ async def add_tip(self, mount: MountType, tip_length: float) -> None:
instr_dict["has_tip"] = True
instr_dict["tip_length"] = tip_length
else:
self._ihp_log.warning("attach tip called while tip already attached")
self._ihp_log.warning(
"attach tip called while tip already attached to {instr}"
)

async def remove_tip(self, mount: MountType) -> None:
instr = self._attached_instruments[mount]
Expand Down
Loading

0 comments on commit 929ef3f

Please sign in to comment.