Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a83b0e1
`Resource`: turn about a joint, and the links and end-effectors that …
BioCam Sep 9, 2026
bbede49
`MechanicalGripper`: define an end-effector by its mechanical interfa…
BioCam Sep 10, 2026
70e43f4
`MechanicalGripper`: drop the `Finger` class, which held nothing a `R…
BioCam Sep 10, 2026
f8b889f
`MechanicalGripper`: delete `bolt_on` and place each part where it goes
BioCam Sep 10, 2026
bb3100a
`MechanicalGripper`: take each part as a size and a place, not a five…
BioCam Sep 10, 2026
df67426
`MechanicalGripper`: name a part's placement `location`, as the resou…
BioCam Sep 10, 2026
aefedd2
`MechanicalGripper`: drop two assertions and a test that cannot fail
BioCam Sep 10, 2026
949d41f
`Link`: drop `far_joint`, which was neither far nor a joint
BioCam Sep 10, 2026
28ad26d
`MechanicalGripper`: take the material as resources, and only place it
BioCam Sep 10, 2026
d6d3a5c
`MechanicalGripper`: name the test's part sizes instead of indexing them
BioCam Sep 10, 2026
18f2dec
`MechanicalGripper`: build the test's fixture from a gripper that exists
BioCam Sep 10, 2026
b4f2774
`MechanicalGripper`: let a gripper have bare fingers, and check a wid…
BioCam Sep 10, 2026
1670e01
`Link`: test that a child link's angle composes on its parent's
BioCam Sep 10, 2026
f600d66
`Resource`: add `rotate_to`, the go-to partner to `rotate`'s move-by
BioCam Sep 10, 2026
393b574
`Link`: turn on the joint through `rotate`, rather than around it
BioCam Sep 10, 2026
0913ddd
Merge upstream/main: compose the pivot on top of quaternion rotation
BioCam Sep 10, 2026
8fcaea7
`Resource`: turn through one shared pivot, and go to an angle exactly
BioCam Sep 10, 2026
88bb90d
`Resource`: leave `rotate` and `rotated` alone, and pivot only where …
BioCam Sep 10, 2026
33c5ac4
`Resource`: test the pivot about every axis, not only z
BioCam Sep 11, 2026
098e9fc
`Resource`: drop two pivot tests that protected nothing
BioCam Sep 11, 2026
809c0cf
`Resource`: go to an angle without a pivot, and drop the joint nothin…
BioCam Sep 11, 2026
2f1364a
`MechanicalGripper`: make `jaw_width` the gap the fingers leave, not …
BioCam Sep 11, 2026
6104cd3
`Link`, `MechanicalGripper`: come back from `serialize` whole
BioCam Sep 11, 2026
406cbe4
`MechanicalGripper`: assert what the pad tests claimed to
BioCam Sep 11, 2026
4a3c346
`MechanicalGripper`: let the grip centre sit off the plane it is moun…
BioCam Sep 11, 2026
ac950af
`MechanicalGripper`: take the tool centre point as one offset
BioCam Sep 11, 2026
905034d
`Resource`: turn about a coordinate other than its own origin
BioCam Sep 11, 2026
d22f4e4
`Resource`: type the pivot test's closure so mypy can see through it
BioCam Sep 11, 2026
a6056b7
`Resource`: let an unplaced resource turn about a pivot rather than r…
BioCam Sep 11, 2026
9e0a9ad
`LinkBody`: make a link's member a cuboid and its joints coordinates …
BioCam Sep 11, 2026
ccbee82
`Resource`: hold a pivot where the location chain actually starts
BioCam Sep 11, 2026
ff2f4c7
`MechanicalGripper`: close the jaws on the grip centre, and state the…
BioCam Sep 11, 2026
a6c5b55
`LinkBody`: test a member and the tool it carries as one arm
BioCam Sep 11, 2026
1116b99
docs: list the manipulator primitives in the changelog and the API index
BioCam Sep 11, 2026
8586c24
`Resource`: decide once whether a turn is pivoted
BioCam Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- User guide notebook for the MicroSpin (`docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb`).
- `Plate`: optional `stacking_z_height` parameter -- the per-plate vertical pitch when plates are stacked directly on top of each other (`size_z` minus the nesting overlap), mirroring `NestedTipRack.stacking_z_height`. Because it is a physical dimension, plates that differ in it no longer compare equal; `Plate` also now serializes `stacking_z_height` and the pre-existing `plate_type` so both round-trip through `deserialize`/`copy`. (#1110)
- `ResourceStack`: bare plates stacked in the z direction now nest into one another by their `stacking_z_height` (a stack of `N` identical plates is `size_z + (N - 1) * stacking_z_height` tall, for both `get_size_z()` and child placement). Plates without a `stacking_z_height`, and plates wearing a lid, do not nest, so existing behaviour is unchanged. (#1112)
- `Resource.rotate_to(x=, y=, z=)`: set the rotation about each axis, where `rotate` turns by an amount. Axes left as `None` keep the angle they had, and each is normalised to `[0, 360)`. (#1249)
- `Resource.rotate`, `rotate_to` and `rotated` take an optional `pivot_coordinate`: a point in the resource's own frame that stays where it is, so a resource can turn about its centre, an edge, or any other point rather than only about its origin. `location` carries by however far the turn moved that point. Raises `NoLocationError` when the resource has no location, since there is nothing to carry. (#1249)
- `LinkBody` (`pylabrobot.resources.LinkBody`): one rigid member of a manipulator, an ordinary resource whose origin is a corner and which carries its `proximal_joint` and `distal_joint` as coordinates within it. The link is the line between the two joints and `length` is the distance, `None` on a member that ends the chain. A member turns about its proximal joint rather than its origin. (#1249)
- `MechanicalGripper` (`pylabrobot.resources.MechanicalGripper`): a `LinkBody` that ends the chain, holding what it takes between two fingers. Its far end is a `tool_center_point` rather than a joint, it is sized to its body because `jaw_width` moves the fingers, and the jaws straddle the grip centre. (#1249)

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions docs/api/pylabrobot.resources.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ Resources represent on-deck liquid handling equipment, including tip racks, plat
ItemizedResource
utils.create_equally_spaced_2d
Lid
LinkBody
Liquid
MechanicalGripper
PetriDish
Plate
PlateCarrier
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
from .corning import *
from .deck import Deck
from .diy import *
from .end_effector import MechanicalGripper
from .eppendorf import *
from .errors import ResourceNotFoundError
from .greiner import *
from .hamilton import *
from .itemized_resource import ItemizedResource
from .lid import Lid, Liddable
from .liquid import Liquid
from .manipulator import LinkBody
from .nest import *
from .opentrons import *
from .perkin_elmer import *
Expand Down
201 changes: 201 additions & 0 deletions pylabrobot/resources/end_effector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""End-effectors: what is fitted at an arm's mechanical interface, and the parts they are made of.

An end-effector - equally a tool, or end-of-arm tooling - is what an arm carries at its wrist
flange so that it can do its task. Its tool centre point is the point a move is programmed
against, stated as an offset from that flange, and it belongs to the tool rather than to the arm:
fit a different one and the point moves with it.

`MechanicalGripper` spans that offset, flange to grip centre, which is why it is a `LinkBody`.
"""

import math
from typing import Any, Dict, Optional, Sequence, Tuple, cast

from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.manipulator import LinkBody
from pylabrobot.resources.resource import Resource
from pylabrobot.resources.rotation import Rotation
from pylabrobot.serializer import deserialize


class MechanicalGripper(LinkBody):
"""A gripper that holds by closing two fingers on what it takes.

A member that ends the chain: nothing attaches past a tool, so it has no distal joint. What sits
at the far end of its span is `tool_center_point`, the point it grips at. Its body, its two
fingers and a pad on each are material bolted to it.

The gap between the fingers is state rather than shape, so `jaw_width` moves them. That is why
the member is sized to its body alone: a box drawn around the fingers would change size every
time the jaws did. The fingers reach past it.
"""

def __init__(
self,
name: str,
proximal_joint: Coordinate,
tool_center_point: Coordinate,
body: Resource,
body_location: Coordinate,
fingers: Sequence[Resource],
finger_location: Coordinate,
jaw_range: Tuple[float, float],
pads: Optional[Sequence[Resource]] = None,
pad_location: Optional[Coordinate] = None,
jaw_width: Optional[float] = None,
category: str = "mechanical_gripper",
model: Optional[str] = None,
):
"""
Args:
name: what to call this one.
proximal_joint: where the joint this gripper turns on sits within it.
tool_center_point: the point it grips at, from this gripper's own origin.
body: the material around the span, which is also what sizes this member.
body_location: where it sits, from this gripper's own origin.
fingers: the two jaws, either side of the span.
finger_location: where a finger sits along and above the span. Its Y is not used: the
jaws straddle `tool_center_point`, and `jaw_width` sets how far apart.
jaw_range: the gap between the fingers, closed and open, in mm.
pads: what each finger meets the resource with, in the same order as `fingers`. A gripper
whose fingers meet it themselves has none.
pad_location: where a pad sits, from the finger it is fixed to. Given with `pads`.
jaw_width: the gap to begin with, in mm. Open, when not given.
"""
super().__init__(
name=name,
# A tool is sized to its body, since the fingers move and a box around them would resize
# with the jaws. The body states that box, so it is not asked for a second time.
size_x=body.get_size_x(),
size_y=body.get_size_y(),
size_z=body.get_size_z(),
proximal_joint=proximal_joint,
distal_joint=None,
category=category,
model=model,
)
if len(fingers) != 2:
raise ValueError(f"a gripper has two fingers, not {len(fingers)}")
if (pads is None) != (pad_location is None):
raise ValueError("pads and pad_location go together: give both, or neither")
# Zipping a short list against a long one would drop material without saying so.
if pads is not None and len(pads) != len(fingers):
raise ValueError(f"a gripper has a pad on each finger, not {len(pads)} on {len(fingers)}")
self.jaw_range = jaw_range
self._tool_center_point = tool_center_point

self.body = body
self.assign_child_resource(body, location=body_location)
self.fingers = list(fingers)
for jaw in self.fingers:
self.assign_child_resource(jaw, location=finger_location)

self.pads = list(pads) if pads is not None else []
for jaw, face in zip(self.fingers, self.pads):
jaw.assign_child_resource(face, location=cast(Coordinate, pad_location))

# Through the setter, which is where a width is checked and the fingers are stood apart.
self.jaw_width = jaw_range[1] if jaw_width is None else jaw_width

@property
def tool_center_point(self) -> Coordinate:
"""Where this tool is programmed against, as an offset from where it is mounted.

Returns:
The grip centre, which the fingers reach past.
"""
return self._tool_center_point

@property
def length(self) -> float:
"""The joint this gripper turns on to the point it grips at, in mm."""
return math.dist(self._tool_center_point.vector(), self.proximal_joint.vector())

@property
def jaw_width(self) -> float:
"""The gap between the fingers' facing surfaces, in mm: what fits between them."""
return self._jaw_width

@jaw_width.setter
def jaw_width(self, width: float) -> None:
low, high = self.jaw_range
if not low <= width <= high:
raise ValueError(f"the jaws open {low} to {high} mm, not {width}")
self._jaw_width = width
self._place_the_fingers()

def _place_the_fingers(self) -> None:
"""Stand the fingers either side of the grip centre, leaving `jaw_width` of gap between them."""
for finger, side in zip(self.fingers, (1.0, -1.0)):
here = cast(Coordinate, finger.location)
# A resource sits at its lowest-y corner: the facing surface on the +Y side, the back of the
# finger on the -Y side. They close on what is at the grip centre, so they straddle the tool
# centre point rather than the joint or the member's own middle.
facing = self._tool_center_point.y + side * self._jaw_width / 2.0
finger.location = Coordinate(
here.x, facing if side > 0 else facing - finger.get_size_y(), here.z
)

def serialize(self) -> dict:
serialized = super().serialize()
# Nothing attaches past a tool, so the key its base emits has nothing to say and
# `__init__` has nowhere to put it.
serialized.pop("distal_joint", None)
return {
**serialized,
"jaw_range": list(self.jaw_range),
"tool_center_point": self.tool_center_point.serialize(),
}

@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False) -> "MechanicalGripper":
"""Rebuild a gripper, taking its own parts back out of its children.

Its body and its two fingers are constructor arguments rather than children assigned after the
fact, so they are read off the front of `children`, in the order `__init__` put them there.
Anything after them is what the gripper was holding.
"""
children = data["children"]
body, *fingers = (
Resource.deserialize(child, allow_marshal=allow_marshal) for child in children[:3]
)
pads = [pad for finger in fingers for pad in list(finger.children)]
for pad in pads:
pad.unassign()

def where(child: dict) -> Coordinate:
return cast(Coordinate, deserialize(child["location"], allow_marshal=allow_marshal))

gripper = cls(
name=data["name"],
proximal_joint=cast(
Coordinate, deserialize(data["proximal_joint"], allow_marshal=allow_marshal)
),
tool_center_point=cast(
Coordinate, deserialize(data["tool_center_point"], allow_marshal=allow_marshal)
),
body=body,
body_location=where(children[0]),
fingers=fingers,
finger_location=where(children[1]),
jaw_range=(data["jaw_range"][0], data["jaw_range"][1]),
pads=pads or None,
pad_location=where(children[1]["children"][0]) if pads else None,
category=data.get("category", "mechanical_gripper"),
model=data.get("model"),
)
rotation = data.get("rotation")
if rotation is not None:
gripper.rotation = cast(Rotation, deserialize(rotation, allow_marshal=allow_marshal))
for child in children[3:]:
gripper.assign_child_resource(
Resource.deserialize(child, allow_marshal=allow_marshal), location=where(child)
)
return gripper

def serialize_state(self) -> Dict[str, Any]:
return {**super().serialize_state(), "jaw_width": self.jaw_width}

def load_state(self, state: Dict[str, Any]) -> None:
super().load_state(state)
self.jaw_width = state["jaw_width"]
Loading
Loading