Skip to content

v1 Mantis: move the arm in the deck frame, guarded, with zone routes and approach paths - #1631

Draft
BioCam wants to merge 1 commit into
mainfrom
mantis-v1-04-arm-moves
Draft

BioCam wants to merge 1 commit into
mainfrom
mantis-v1-04-arm-moves

Conversation

@BioCam

@BioCam BioCam commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

The Problem

The Mantis can bring its arm up and stop it, but not move it. A move has to be given in PyLabRobot's deck frame, where wells and resources are, while the arm turns two motors through a five-bar linkage and every unit is calibrated at the factory, its plate holder mapped by a stage transform with its own tilt and reference height. A move across the deck must first clear what stands on it, and from wherever the head ends up (any zone of the deck, the waste, a wash) the arm needs a safe way back to idle.

PR Content/Solution

The arm's configuration

mantis.arm.configuration (MantisArmConfiguration, frozen) holds the motor limits read at setup and the unit's calibration from its configuration folder or a saved file: offsets, encoder reference and correction, zones and their routes, approach paths, and the stage transform with its reference height. A block that is missing is None, and a move that needs it refuses.

mantis = Mantis(name="mantis", declared_configuration_json="my_mantis.json")
await mantis.setup()

configuration = mantis.arm.configuration
configuration.zone_rectangles.keys()  # Front, RightBack, RightMiddle, RightFront, LeftBack, ...
configuration.approach_paths.keys()  # waste, wash_1, wash_2
configuration.stage_reference_height  # mm

Moves in the deck frame

The nozzle is the tool point. Over the plate holder the deck frame goes through the unit's exact stage transform; elsewhere through a rigid transform fitted to it. From inside the waste or a wash, every move first leaves along that path.

method moves
move_to_location(*, location, minimum_traverse_height_start=None, xy_speed, xy_acceleration, z_speed, z_acceleration) across, then down to the location
move_to_xy_position(*, x, y, minimum_traverse_height_start=None, speed, acceleration) across, Z held
move_to_z_position(*, z, speed, acceleration) Z only, the arms held
move_to_joint_state(*, joint_state, minimum_traverse_height_start=None, ...) the motors named, the others held
from pylabrobot.resources import Coordinate
from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb

wellplate = cor_96_wellplate_360uL_Fb(name="wellplate")
mantis.plate_holder.assign_child_resource(wellplate)

a1 = wellplate.get_well("A1").get_location_wrt(mantis.deck, x="c", y="c", z="t")
await mantis.arm.move_to_location(location=a1 + Coordinate(z=12.5))
await mantis.arm.move_to_z_position(z=a1.z + 2.5)

h12 = wellplate.get_well("H12").get_location_wrt(mantis.deck, x="c", y="c", z="t")
await mantis.arm.move_to_xy_position(x=h12.x, y=h12.y, minimum_traverse_height_start=a1.z + 5)

await mantis.arm.move_to_joint_state(joint_state={2: 5.0})  # Z 5 mm down from its flag

Guarded, as v1 STAR

Every move across is guarded: Z first rises to minimum_traverse_height_start (default_minimum_traverse_height when None) if it stands below it, and is read back by its encoder before the arms move.

Speeds in standard units

Speeds and accelerations are mm/s and mm/s² at the nozzle, each range-checked when it is set. Left None, the arms move at the route profile.

mantis.arm.default_xy_speed = 50.0  # mm/s
mantis.arm.default_xy_acceleration = 200.0  # mm/s²
mantis.arm.default_z_speed = 20.0
mantis.arm.default_minimum_traverse_height = None  # the safe height

mantis.arm.default_z_speed = 60.0  # ValueError: default_z_speed must be from 1.0 to 55.0, got 60.0

Zones, parking and recovery

The deck is divided into zones, each with its own route to idle. park() and setup() reach idle from any zone along that zone's route, and recover_from_fault() stops, clears the faults and brings the arm back.

await mantis.arm.park()
await mantis.arm.request_is_parked()  # True
await mantis.arm.recover_from_fault()

The waste and the washes

move_to_waste() and move_to_wash(*, position) work from wherever the head stands. Where it stands is read from the motors, never remembered: from inside one of these, the head first leaves along that path, and between the two washes it crosses without leaving the wash.

await mantis.arm.move_to_waste()
await mantis.arm.move_to_wash(position=1)  # out of the waste, into wash 1
await mantis.arm.move_to_wash(position=2)  # across, inside the wash
await mantis.arm.park()  # out along wash 2's path, then to idle

Reads

await mantis.arm.request_joint_state()  # {0: deg, 1: deg, 2: mm down from Z's flag}
await mantis.arm.request_absolute_joint_state()  # the arms by their absolute encoders
pose = await mantis.arm.request_pose()  # MantisPose: elbows, head and nozzle in the deck frame
pose.nozzle_location
await mantis.arm.request_is_parked()
mantis.arm.is_xy_reachable(x=15.0, y=60.0)  # in the arm frame

Also

  • 3D model: the arm's links follow the arm after every move.
  • Simulator: moves, zones, approach paths and recovery all run on Mantis(simulation=True).

On the device

A Mantis v4 on Windows (D2XX), head clear, no chip:

result
refusals before and after a bring-up every one raised, no move sent
cold bring-up at idle no fault, arms within 3·10⁻⁸ device units of idle
move_to_z_position, move_to_joint_state one queued move each; Z at 9.997 mm for 10
move_to_xy_position in the deck frame the head within 1.1 µm of its target
the guard (traverse height: the safe height) Z rose and was read back before the arms moved, also when park() started 6.5 mm below it
move_to_waste(), then move_to_wash(1) from the waste, then move_to_wash(2) from wash 1 no fault; the head seen in the waste and in wash 2; wash 1 to wash 2 inside the wash
park() out of the waste, move_to_xy_position out of wash 2 each left along its path first, then parked
recover_from_fault() at idle back at idle in 3.2 s
stop() away from idle abort, three motor stops, Z read back before the arms, Z down to 20 mm
setup() from LeftMiddle, RightFront, RightMiddle, LeftBack and LeftFront each zone's route to idle, arms within 10⁻⁷ device units of idle

Not yet on the device: moves over the wellplate; recovery from RightBack and from a zone after a power cycle; park() out of a wash; move_to_wash(1) from wash 2; stop() from away along a zone's route; a traverse height set in the deck frame.

Known, to fix in this PR

  • Leaving the waste along its path, one item moves the arms and raises Z together while Z is still deep.
  • On an approach path, some argument checks run only after the head has left the path, and move_to_z_position or a Z-only move_to_joint_state first move the arms out along it.
  • recover_from_fault() and setup() move the arms along a zone's route before Z rises.
  • A queued move that times out or is cancelled is aborted but not stopped: the item playing finishes.
  • With xy_speed given and xy_acceleration not, the two arm motors do not end together.
  • is_xy_reachable takes arm-frame coordinates.
  • MantisPose and MantisArmConfiguration are not in docs/api; the notebook's arm cells need the unit's configuration folder to run in simulation.

🤖 Generated with Claude Code

…s and approach paths

The arm's configuration on mantis.arm.configuration; moves in the deck frame through the unit's
stage transform, each move across guarded by a rise to the traverse height; speeds in mm/s at the
nozzle; park and recovery along each zone's route; move_to_waste and move_to_wash from wherever
the head stands; request_pose for every joint in the deck frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BioCam
BioCam force-pushed the mantis-v1-04-arm-moves branch from f98893f to e6234df Compare November 13, 2026 20:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant