PreciseFlex: lifecycle verbs, a motion barrier, gripper axis limits, single-axis moves and a scoped speed - #1239
PreciseFlex: lifecycle verbs, a motion barrier, gripper axis limits, single-axis moves and a scoped speed#1239miikee wants to merge 6 commits into
Conversation
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.
| 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. | ||
| """ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
I think the comment is wrong.
I think the name is confusing though. This isn't a width, its 2 positions for soft limits.
| async def _set_grasp_data( | ||
| async def set_grasp_data( | ||
| self, plate_width: float, finger_speed_pct: float, grasp_force: float | ||
| ) -> None: |
There was a problem hiding this comment.
this should stay private because it's always used in a pick command. we should minimize the state changes on the robot
There was a problem hiding this comment.
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
| @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 | ||
|
|
There was a problem hiding this comment.
isnt this required for operation? ie if we cant load the config, we should not allow initialization to complete
| 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) |
There was a problem hiding this comment.
what is the justification for having relative commands? they are almost always annoying
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
I think if we dont have the soft min and max, we should not allow the code below to proceed
There was a problem hiding this comment.
Sounds good - I'll fix this
| """ | ||
| if self._gripper_soft_min is None or self._gripper_soft_max is None: | ||
| return units |
There was a problem hiding this comment.
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>
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 intoconnect()/initialize()/disconnect(). Itdid 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
connectnorinitializemoves the arm;home()is still the only verb thatsweeps it, and
setup()calls all of them in order so existing callers areunaffected. Configuration discovery moves into
_discover_configuration()andstays best-effort, so
has_configurationnow says whether the arm actually readits own limits.
2. The gripper and the rail wait for the arm to stop.
moveJreturns whenthe controller accepts it, not when the arm arrives, and the connection stays
free during travel. Joint and Cartesian moves already wait behind
_wait_for_eomvia_guarded_move_j;move_gripper,move_gripper_joint_positionandmove_raildid not. A grip issued after anapproach 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 limitsstraight 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 oneaxis 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_saferuns the controller's ownmovetosafe,which was buried in
park()'s fallback branch and unreachable on its own._set_grasp_databecomes public, since a caller composing its own pick has toset the grip before it.
5.
at_speed.move_to_location'sspeed_pctwrites the profile speed andnever puts it back, so a slow approach left the arm slow for everything after
it.
at_speedreads the current speed, sets the new one and restores in afinally, 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.5is anumber 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