Skip to content

PreciseFlex: lifecycle verbs, a motion barrier, gripper axis limits, single-axis moves and a scoped speed - #1239

Open
miikee wants to merge 6 commits into
PyLabRobot:mainfrom
Cheshire-Labs:plr/precise-flex
Open

PreciseFlex: lifecycle verbs, a motion barrier, gripper axis limits, single-axis moves and a scoped speed#1239
miikee wants to merge 6 commits into
PyLabRobot:mainfrom
Cheshire-Labs:plr/precise-flex

Conversation

@miikee

@miikee miikee commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Five changes to the PF400 driver, found running one on a bench. They are five
commits on one file, in order, and each stands on its own if you would rather
take them separately.

1. setup() splits into connect() / initialize() / disconnect(). It
did four things behind one name. A caller that wants to read a position, or to
reconnect after a controller restart, had no way to ask for part of it. Neither
connect nor initialize moves the arm; home() is still the only verb that
sweeps it, and setup() calls all of them in order so existing callers are
unaffected. Configuration discovery moves into _discover_configuration() and
stays best-effort, so has_configuration now says whether the arm actually read
its own limits.

2. The gripper and the rail wait for the arm to stop. moveJ returns when
the controller accepts it, not when the arm arrives, and the connection stays
free during travel. Joint and Cartesian moves already wait behind
_wait_for_eom via _guarded_move_j; move_gripper,
move_gripper_joint_position and move_rail did not. A grip issued after an
approach closes the jaws on the way down onto the plate. This is the one I would
take first.

3. Gripper targets stay inside the axis, and widths anchor on the calibration
pair.
Commanding past the end of the gripper axis leaves a standing position
error the controller reports as an overheating motor (-3104), after which it
refuses every move until the arm is homed. It happened twice in one session:
opening to the advertised maximum of 134.0 landed the axis at 134.062, because
the servo overshoots. Separately, setup() copied the discovered axis limits
straight into min_gripper_width / max_gripper_width, which are millimetres,
so every width silently changed meaning after discovery.

4. move_one_axis, move_one_axis_relative, move_to_safe. Moving one
axis meant building a whole joint pose. The relative form offsets from the pose
read inside the guarded move, so a retry after recovery offsets from where the
axis actually ended up. move_to_safe runs the controller's own movetosafe,
which was buried in park()'s fallback branch and unreachable on its own.
_set_grasp_data becomes public, since a caller composing its own pick has to
set the grip before it.

5. at_speed. move_to_location's speed_pct writes the profile speed and
never puts it back, so a slow approach left the arm slow for everything after
it. at_speed reads the current speed, sets the new one and restores in a
finally, so a fault mid-move cannot strand the arm slow.

Tests: 82 in precise_flex_tests.py, all passing, plus the rest of the suite.

Two things I would rather flag than bury. _GRIPPER_LIMIT_HEADROOM = 0.5 is a
number measured on our gripper; happy to make it a constructor argument if you
would rather not have a bare module constant. And the settle barrier in (2) has
no opt-out, so pre-positioning the jaws during travel is no longer possible --
say the word and I will add a wait: bool = True.

🤖 Generated with Claude Code

setup() did four things behind one name: open the socket, agree the response
mode, raise high power and attach, then home. A caller that wants to read a
position, or to reconnect after a controller restart, had no way to ask for
part of that.

connect() opens the link and sets the response mode. initialize() raises
power, attaches, leaves freedrive and reads the controller's configuration.
disconnect() detaches, drops power and closes the link. Neither connect nor
initialize moves the arm; home() is still the only verb that sweeps it, and
setup() still calls all of them in order, so existing callers are unaffected.

Configuration discovery moves into _discover_configuration() and stays
best-effort. Because it can fail, has_configuration says whether the arm
actually read its own limits, which a caller that would rather adapt than be
raised at can now check.
…r the rail

moveJ returns as soon as the controller accepts it, not when the arm arrives,
and the connection stays free during travel so the next command is sent
immediately. Joint and Cartesian moves already wait behind _wait_for_eom via
_guarded_move_j, but move_gripper, move_gripper_joint_position and move_rail
did not, so each could act while the arm was still moving. A grip issued after
an approach closes the jaws on the way down onto the plate.

All three now wait first. _wait_for_eom's docstring says it is the barrier
every motion-starting command crosses, and which commands reach it directly
rather than through the guarded move.
…on the calibration pair

Two faults on the same axis.

Commanding the gripper past the end of its axis leaves a standing position
error that the controller reports as an overheating motor (-3104), after which
it refuses every move, gripper or not, until the arm is homed. It happened
twice in one bench session: opening to the advertised maximum of 134.0 landed
the axis at 134.062, because the servo overshoots. move_gripper and
move_gripper_joint_position now hold every target half a unit inside the
discovered soft limits via _within_gripper_limits, and gripper_joint_range
exposes those limits so a caller above the driver can see the ceiling it must
not compute past.

Separately, setup() copied the discovered gripper-axis limits straight into
min_gripper_width and max_gripper_width, which are millimetres. That put axis
units in a millimetre field and silently changed what every width meant after
discovery. Both ends now convert through the construction-time calibration
pair, which _anchor_width_mm pins, so a width commands the same jaw travel
before and after discovery. The range check gains an epsilon, since an
advertised end converts back a few ulps outside the limit it came from.

gripper_width_range on the configuration is typed and says it returns
controller units, not millimetres.
Moving a single axis meant building a whole joint pose and hoping the other
axes carried their live values. move_one_axis takes an axis and an absolute
target; move_one_axis_relative takes a signed offset. Both go through the same
guarded move as any other commanded motion, so a blocked axis still recovers
and retries. The relative version offsets from the pose read inside the
guarded move rather than from a reading taken beforehand, so a retry after
recovery offsets from where the axis actually ended up.

move_to_safe runs the controller's own movetosafe retraction. It was buried
inside park()'s fallback branch and unreachable on its own. It is a route the
controller plans itself, so the docstring says what cannot be checked from
here: the pose is not readable and not validated against the soft limits.

_set_grasp_data becomes set_grasp_data. A caller that composes its own pick
has to be able to set the grip before it, and there is nothing private about
writing GraspData.
… the arm slow

move_to_location's speed_pct sticks: it writes the profile speed and never puts
it back, so a slow approach left the arm slow for everything after it. There
was no way to run a single move at its own speed.

at_speed() is that primitive. It reads the current speed, sets the new one, and
restores in a finally, so a fault mid-move cannot leave the arm slow. If the
restore itself fails it logs what the arm is still set to before raising,
because at that point nothing else will reset it. Passing None does nothing at
all, so a caller can hand a speed straight through without branching.

A caller scopes whatever it likes inside it, including the rail traverse, which
matters on a place: the arm is carrying the plate down the rail, and that is
the leg the caller asked to go slowly. move_to_location's docstring now says
its speed sticks and points at at_speed for the scoped case.
This was referenced Sep 3, 2026
Comment on lines +84 to +89
def gripper_width_range(self) -> tuple[float, float]:
"""Travel limits of the gripper axis, in the controller's own units.

Not millimetres: a jaw width reaches these through
``PreciseFlex.closed_gripper_position``, so callers must convert.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we not convert it in here so every python function is in standard PLR units? we should only convert to/from machine units in the functions that directly interact with hardware, users should not have to care

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the comment is wrong.

I think the name is confusing though. This isn't a width, its 2 positions for soft limits.

Comment thread pylabrobot/brooks/precise_flex/precise_flex.py
Comment on lines -1341 to 1418
async def _set_grasp_data(
async def set_grasp_data(
self, plate_width: float, finger_speed_pct: float, grasp_force: float
) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should stay private because it's always used in a pick command. we should minimize the state changes on the robot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved away from using the pick command to maintain more control over the approach. So I compose a sequence of motions to perform the pick and use set_grasp_data to set the gripper grasp.

To avoid state change, we could have something do the motion such as: grasp(position, grasp_force, finger_speed_pct)

but then this is duplicating the move_gripper(self, width: float, force_sensing: bool = False) function

Comment on lines +1702 to +1710
@property
def has_configuration(self) -> bool:
"""Whether the controller's configuration was actually read.

Discovery is best-effort, so an arm can finish setup and still not know its own
limits. A caller that would rather adapt than be raised at asks this first.
"""
return self._configuration is not None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isnt this required for operation? ie if we cant load the config, we should not allow initialization to complete

Comment on lines +2175 to +2195
async def move_one_axis_relative(
self,
axis: Axis,
distance: float,
speed_pct: Optional[float] = None,
) -> None:
"""Shift one axis by ``distance`` from where it is now, leaving the others alone.

The offset is applied to the pose read inside the guarded move rather than to a
position read beforehand, so it cannot act on a stale reading. If the first
attempt is blocked and recovery shifts the axis, the retry offsets from the
recovered position, which is what a relative move should mean.

Args:
axis: The axis to move.
distance: Signed offset to apply to that axis, in the axis's own units.
speed_pct: Movement speed override as a percentage (0-100). If None, uses the
current speed setting.
"""
if speed_pct is not None:
await self._set_speed(speed_pct)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the justification for having relative commands? they are almost always annoying

@miikee miikee Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually one of my most used commands lol. I often need to lift the base ~10mm to clear something before I can move it back to a safe position.

f"closed_gripper_position (currently {self.closed_gripper_position})."
)
if self._gripper_soft_min is not None and self._gripper_soft_max is not None:
# An advertised end converts back through a subtract and an add, so it can land a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we dont have the soft min and max, we should not allow the code below to proceed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - I'll fix this

Comment on lines +2555 to +2557
"""
if self._gripper_soft_min is None or self._gripper_soft_max is None:
return units

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this valid?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably just refuse

…a gripper move with no limits

Discovery was best-effort: a failed read logged a warning and initialize()
carried on. The link lengths come from that read, so an arm that finished
bring-up without one solves IK for a different machine, and the gripper has no
soft limits to hold a target inside. Both are now fatal to initialize(), which
is what a caller can act on. has_configuration goes with it; there is nothing
left to ask once bring-up either succeeded or raised.

A gripper move with no limits used to pass the target through untouched, so the
one case where the driver knows least about the arm was the one case it checked
nothing. Commanding past the end of the axis is what leaves a standing position
error the controller reports as an overheating motor, after which it refuses
every move until the arm is homed. move_gripper and move_gripper_joint_position
now refuse instead, naming the missing bring-up rather than the missing limits.
The window is still reachable after connect() and before initialize(), which is
why the check is at the movers and not only at discovery.

The gripper_width_range docstring claimed the value was not in millimetres.
Whether the gripper axis is millimetre-scaled is not established anywhere in
this driver, so the claim comes out rather than being restated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@miikee
miikee marked this pull request as ready for review September 4, 2026 14:42
@miikee
miikee requested a review from a team as a code owner September 4, 2026 14:42
Copilot AI lite review requested due to automatic review settings September 4, 2026 14:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

3 participants