Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
108 changes: 108 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,114 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Joint trajectories now accept `JointGripperForceTarget(force)` alongside the
legacy exact-width target. It dispatches signed native
`Gripper.Grasp(force)` at the segment boundary: positive closes, negative
opens, and measured width remains state rather than an action.

### Changed
- The strict trajectory RPC/schema is v4. Gripper targets are explicitly tagged
as `move` or `force`, preventing a force action from being decoded as a width.

## [0.2.4] - 2026-08-01

### Fixed
- Blocking `Gripper.Move` calls now wait for the commanded width instead of
accepting pre-motion stillness during GN01 actuation latency. Obstructed
stillness remains a valid completion condition only for `Gripper.Grasp`.

## [0.2.3] - 2026-07-31

### Added
- Joint trajectories can carry an explicit actuator-target knot 0, exact
per-waypoint gripper targets, and strict authoritative `n_frames` timing.
- The server records acknowledged joint and gripper endpoints as provenance
while rebasing each feedback-MPC prefix to current measured state.

### Changed
- The joint trajectory wire schema and trajectory RPC identity are v3 and
refuse older or structurally loose payloads before motion.
- Strict joint trajectories are fully prevalidated against effective runtime
joint and `Gripper.params` limits; violations reject instead of clipping or
time-stretching. `Gripper.Move` dispatches fire-and-forget at each segment
boundary while arm streaming continues in the same RPC.

### Fixed
- Strict execution now bounds the actual first emitted joint target from
measured state on every RPC and derives each prefix's first gripper ramp from
measured width; it repeats both checks immediately before dispatch so state
drift across a mode transition cannot bypass the bound. Prior acknowledged
targets are diagnostic provenance only.
- Fault/contact gates run before mode or actuator writes, pure prevalidation
failures preserve the acknowledged provenance cache, and server lifecycle
boundaries clear it.
- Joint v3 numeric fields reject JSON strings and booleans instead of coercing
them into numbers.

## [0.2.2] - 2026-07-31

### Changed
- Bumped the trajectory RPC identity and server-info schema to v2. The
lease-free identity response now binds control rate, active safety profile,
connected hardware facts, runtime gripper limits, and the effective joint
limit contract.
- Joint trajectories now carry an explicit linear/cosine interpolation law,
honour per-trajectory speed scaling against per-joint runtime limits, and
allocate controller ticks from cumulative waypoint time.
- Flexiv RDK state and gripper-limit reads now fail closed instead of
fabricating zero-valued observations or accepting unchecked commands.

## [0.2.1] - 2026-07-30

### Changed
- Unified the v0.2.0 SpaceMouse/ROS gripper and twist fixes with the canonical
trajectory API used by ActAhead (`JointTrajectory`,
`execute_joint_trajectory`, and the corresponding wire protocol).
- Client and robot-control server deployments can now bind to one exact source
commit instead of combining the trajectory client from a post-v0.1.8 branch
with a divergent v0.2.0 action-chunk server.

## [0.2.0] - 2026-07-09

Fixes from the July 2026 cross-repo audit against `flexiv-spacemouse-teleop`.

### Fixed
- **SpaceMouse gripper toggle was level-triggered**: holding the button flipped
the gripper open/closed once per control tick (~100 Hz on the default loop).
It now toggles only on the button's rising edge, tracked every tick (also
while the deadman is released and inside `intervention()`), matching the ROS
teleop bridge's behaviour.
- **ROS bridge `~/delta_twist_cmds` treated unitless commands as m/s**: the
node advertises itself as a drop-in target for a MoveIt-Servo teleop
pipeline, but such pipelines publish joystick-style values in `[-1, 1]` that
Servo scales down (0.4 m/s / 0.8 rad/s in the flexiv_ros2 config). Feeding
them straight in executed ~2.5x faster than the Servo path. The node now
defaults to `twist_in_type: unitless` with matching `twist_scale_linear` /
`twist_scale_rotational` parameters (`speed_units` restores the old
behaviour), and drops commands older than `twist_max_age` (default 0.25 s).

### Changed
- Gripper toggle conventions now match the lab's ROS teleop bridge: GN01
widths 0.09 m open / 0.01 m close (was 0.08 / 0.0). The initial open/closed
state is inferred from the robot's reported gripper width on first use
(fallback: closed, so the first press opens); override with `initial_open`.
- The gripper button is no longer deadman-gated in `run()`: a rising edge with
the deadman released actuates the gripper with zero motion (previously the
edge was consumed and the internal state flipped while the command was
dropped, desynchronizing state from the physical gripper).
- The ROS bridge integrates twists over the actual inter-message interval
(capped at `twist_max_age`) instead of a fixed `robot.dt`, so the realized
speed no longer scales with the publisher's rate; jog parameters are
validated before the robot is connected.
- `SpaceMouseTeleop` accepts `signs` (six +/-1 values) to calibrate device
axes to the robot frame without a custom source subclass.

### Housekeeping
- Versions 0.1.6-0.1.8 were lab-internal iterations installed on the control
PC without changelog entries or tags; their changes are folded into this
release.

## [0.1.5] - 2026-06-19

### Fixed: 3 more flexiv_rdk-backend bugs from the first real-hardware session
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ keywords:
- real2sim2real
- LeRobot
license: Apache-2.0
version: 0.1.0
version: 0.2.3
34 changes: 28 additions & 6 deletions docs/action_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,16 @@ JointTrajectory(
)
```

## `GripperCommand`
## `GripperCommand` and direct effort MPC

A parallel-jaw gripper is **continuous**, not binary: the canonical/hardware
command is an opening `width` in metres (+ `force`, `velocity`), matching Flexiv
RDK `Gripper.Move(width, velocity, force_limit)` / `Gripper.Grasp(force)`. The
`0/1` you see in learning benchmarks is a normalized *abstraction* on top of this
continuous width — not a hardware limit.
The library exposes two explicit, non-interchangeable gripper contracts. Legacy
position trajectories use opening `width` through Flexiv RDK
`Gripper.Move(width, velocity, force_limit)`. Direct-effort MPC uses
`JointGripperForceTarget(force)` through `Gripper.Grasp(force)`: positive Newtons
close, negative Newtons open, and measured width is state rather than an action.
The runtime `GripperParams.min_force/max_force` values are authoritative; a
device that does not support a requested signed force is rejected before any
trajectory write. Neither contract is an implicit binary open/close flag.

```python
GripperCommand(
Expand All @@ -200,6 +203,25 @@ GripperCommand(
GripperCommand.from_normalized(0.7, span=0.08) # width = 0.7 * 0.08 m
```

For an effort trajectory, use the separately tagged action:

```python
JointGripperForceTarget(force=-20.0) # direct opening force, if live limits allow
JointGripperForceTarget(force=35.0) # direct closing force
```

If a planner samples a dimensionless signed-effort latent, decode it exactly
once before constructing the RPC payload:

```python
JointGripperForceTarget.from_signed_effort_latent(z, force_limit=80.0)
# z in [-1,1]: positive closes, negative opens; payload is physical Newtons
```

This is intentionally different from the legacy positional
`GripperCommand.from_signed_action`, where positive means open and the decoded
quantity is a width in metres. Neither latent convention appears on the wire.

**`grasp=True` ignores `width` on hardware**: the RDK backend calls
`Gripper.Grasp(force)` and the fingers close until contact at the force limit
-- a planner that encodes close-intent by thresholding width must not expect
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,8 @@ <h3 style="font-family:var(--disp);font-weight:600;font-size:16px;margin:0 0 10p
</div>
<div style="height:14px"></div>
<div class="card">
<h3 style="font-family:var(--disp);font-weight:600;font-size:16px;margin:0 0 8px">Continuous, not binary</h3>
<p style="margin:0;color:var(--muted);font-size:14px">A parallel-jaw gripper is continuous width (0–0.10 m), not 0/1. The 4-bar actuator is <code style="font-family:var(--mono);color:var(--blue)">ctrl = 9.404·w − 0.155</code>; <code style="font-family:var(--mono);color:var(--blue)">GripperCommand(width=…)</code> maps straight through.</p>
<h3 style="font-family:var(--disp);font-weight:600;font-size:16px;margin:0 0 8px">Continuous position or direct effort</h3>
<p style="margin:0;color:var(--muted);font-size:14px">Width trajectories use <code style="font-family:var(--mono);color:var(--blue)">Gripper.Move</code>. Physics-sampling MPC can instead send signed Newton targets with <code style="font-family:var(--mono);color:var(--blue)">JointGripperForceTarget</code> and <code style="font-family:var(--mono);color:var(--blue)">Gripper.Grasp</code>; measured width remains state, not the sampled action.</p>
</div>
</div>
</div>
Expand Down
69 changes: 69 additions & 0 deletions docs/integration_mpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,75 @@ with RemoteRobot("ROBOT_HOST_IP", 8766, owner="mpc") as r:
`RemoteRobot` mirrors the `Robot` API over newline-JSON/TCP, holds the lease with
a heartbeat, and installs with only numpy.

## Strict receding-horizon joint prefixes

For physics-sampling MPC whose action is generalized effort, use
`JointTorqueTrajectory` instead of the position-target example below. It streams
seven additional joint torques at 1 kHz with RDK nonlinear-dynamics compensation
and firmware soft limits enabled, and dispatches an optional
`JointGripperForceTarget` at the same segment boundaries. This API is disabled by
default and is admitted only when the deployment, live `RobotInfo.tau_max`,
safety profile, continuity anchor, and gripper force limits all agree. See
[`joint_torque_mpc.md`](joint_torque_mpc.md).

Use one atomic `execute_joint_trajectory` call for a multi-segment actuator
prefix. Knot 0 is the previous commanded target, not a fresh measured-state
sample; normal physical tracking lag therefore does not bend the next spline.

```python
import numpy as np
from flexiv_control import JointGripperTarget, JointTrajectory, JointWaypoint

knot0 = np.asarray(previous_ack_target, float) # [q0..q6, gripper_width]
knots = np.asarray(prefix_targets, float) # shape (N, 8)
frames = [32, 1]

traj = JointTrajectory(
initial_positions=knot0[:7],
initial_gripper_width=float(knot0[7]),
waypoints=[
JointWaypoint(
positions=knot[:7],
gripper=JointGripperTarget(
width=float(knot[7]),
force=20.0,
velocity=None, # derive abs(delta_width) / segment duration
),
n_frames=n_frames,
)
for knot, n_frames in zip(knots, frames)
],
interpolation="linear",
strict_timing=True,
max_joint_speed_scale=0.3,
)
result = robot.execute_joint_trajectory(traj)
```

Every strict segment is prevalidated against the effective runtime joint rates
and cached `Gripper.params` before any backend write. Requested `n_frames` are
authoritative: a violation raises instead of clipping or time-stretching. A
provided gripper velocity must equal the velocity that realizes its width delta
in the same segment; `None` derives it. Position-mode `Gripper.Move` is issued
fire-and-forget at the segment boundary, then the arm streams concurrently.

Knot 0 need not equal the prior commanded endpoint because it is not streamed.
Every feedback-MPC call rebases its joint safety filter and first gripper ramp to
current measured state. The actual first interpolated joint target must be
reachable from that measurement in one controller tick under the effective
per-joint rate limits; gripper velocity is likewise derived and validated from
current measured width. This permits the next plan's knot 0 to reflect ordinary
tracking error without hiding a command jump. The previous acknowledged joint
and gripper targets remain in the result log as provenance, not an admission
gate. The executor repeats the first-setpoint check on the freshest snapshot
after any mode transition, immediately before actuator writes, and recomputes
the first gripper-event ramp from width measured at dispatch. Stop, fault, lease
changes, mode changes, and other mutating RPCs clear
that provenance cache. A payload rejected completely during prevalidation
leaves it intact for diagnosis. The result log also includes requested/scheduled
segment and total ticks, interpolation and execution anchors, gripper events,
and measured gripper tracking.

## Safety notes for MPC

- Keep the safety filter on (it is, by default). A misbehaving solver that
Expand Down
10 changes: 10 additions & 0 deletions docs/integration_teleop.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ ROS-native teleop stack and you do not need to replace it.
repo and your RL/MPC code share one controller and one safety profile instead
of two parallel control paths.

Mind the **unit semantics** when you rewire: a MoveIt-Servo-style joystick
pipeline publishes *unitless* values in `[-1, 1]` that Servo scales to real
speeds. The bringup node's default (`twist_in_type: unitless`,
`twist_scale_linear: 0.4` m/s, `twist_scale_rotational: 0.8` rad/s) matches
the flexiv_ros2 Servo config, so the same command stream moves at the same
speed. If your publisher sends real m/s / rad/s instead, set
`twist_in_type: speed_units`. Stale commands older than `twist_max_age`
(default 0.25 s) are dropped. Re-verify speeds on the real arm after any
rewiring.

Either way, the win is a single execution-and-safety layer underneath teleop,
RL, MPC, and a high-level planner — not three different ways to command the same arm.

Expand Down
55 changes: 55 additions & 0 deletions docs/joint_torque_mpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Direct joint-torque MPC execution

`flexiv-control` exposes `JointTorqueTrajectory` for controllers whose sampled
action is physical joint effort rather than a joint-position or velocity
target. It is intentionally separate from `JointTrajectory`: callers cannot
accidentally reinterpret torque as position.

Hardware execution is fail closed unless all of the following hold:

- the robot configuration explicitly sets `allow_joint_torque: true`;
- the server runs at exactly 1 kHz;
- Flexiv RDK exposes `RT_JOINT_TORQUE` and `StreamJointTorque`;
- live `RobotInfo.tau_max` exists and has one positive value per joint;
- the requested `max_joint_torque_scale` does not exceed the active profile;
- every initial/waypoint torque is within the scaled live limits;
- the normal lease, E-stop, fault, wrench, cancellation, and soft-limit gates
remain active.

The RDK call enables nonlinear dynamics compensation and firmware soft limits.
Waypoint endpoints are linearly connected at 1 kHz because Flexiv requires
smooth continuous torque commands. Gripper force events are synchronized with
the same segment boundaries and remain physical Newton commands, not width
targets.

Keep the planner's dimensionless semantic coordinate separate from the wire
action. For ActAhead's convention, `latent=+1` means maximum closing force,
`latent=-1` maximum opening force, and decoding happens exactly once:

```python
target = JointGripperForceTarget.from_signed_effort_latent(
latent,
force_limit=80.0, # deployment value; still checked against live limits
)
```

The resulting target stores and transmits only `force` in Newtons. It does not
transmit the latent or a desired width. Do not use
`GripperCommand.from_signed_action` for this path: that legacy positional API
uses the opposite sign convention (`+1` means open) and decodes to metres.

This matches the public Flexiv RDK semantics: `Gripper.Grasp(force)` is direct
force control, with positive force closing and negative force opening. Admission
is always checked against the connected gripper's live `min_force` and
`max_force`; the controller never assumes that a particular GN01 firmware
supports the full signed range.

This API supplies an execution mechanism, not a planner objective. Sampling a
torque and penalizing squared torque in a cost function are independent design
choices.

On success, `ExecutionResult.log` acknowledges the physical command endpoints
as `acknowledged_ending_joint_torque_nm` and
`acknowledged_ending_gripper_force_n`. `ExecutionResult.final_state` remains
the measured end state, including gripper width and measured force; callers
must not reconstruct measured aperture from the semantic latent.
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,8 @@ addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py38"

[tool.ruff.lint]
# Pin the repository's historical Ruff default instead of inheriting changes
# to the tool's default rule set across releases.
select = ["E4", "E7", "E9", "F"]
Loading
Loading