From 6a18beabefa9564d9b66c93fd671a8c7a4548601 Mon Sep 17 00:00:00 2001
From: Johnny Nunez
Date: Mon, 3 Aug 2026 01:28:12 +0200
Subject: [PATCH] Couple the gripper fingers and repair the USD regeneration
pipeline
The gripper jaws drift on their own whenever the arm moves. This is an asset
defect, not a solver one: it reproduces identically in PhysX, Newton and MuJoCo
because all three read the same authored gains.
Two independent causes, both fixed here.
1. The fingers were 9,248x softer than the actuator they model.
Every arm joint sits at zeta 18-49 (overdamped) while the fingers were at
K = 100 N/m, zeta = 0.685, f_n = 5.45 Hz -- underdamped, so any teleop motion
excites them. They sagged 7.4 mm under gravity alone (14.75% of the 50 mm
stroke) and peaked 2.3 mm during an arm sweep.
The new gains come from the hardware, not from tuning. Motor 7 is a RobStride
whose limit_torque reads 14 Nm live from the firmware over CAN; it drives both
racks through one pinion of r = 7.353 mm/rad (derived from the calibrated
0 -> -6.8 rad travel over the 50 mm stroke). Reflecting the motor's 50 Nm/rad
through that transmission gives K = 50/r^2 = 925 kN/m and F = 14/r = 1904 N at
the finger. 925 kN/m is far above what the solver can integrate at 1/120 s, so
the drive is capped at the stiffest gain that stays smooth, with damping set
for zeta = 1.0.
Same reasoning for the limits: effort 500 -> 1904 N, and maxVelocity
10 -> 0.243 m/s (10 m/s was ~41x faster than the motor's 33 rad/s can drive
the fingers).
2. The fingers were modelled as two independent DOFs.
The real gripper is one motor, one pinion and two opposed racks (hardware BOM
ships 02_Rack.step x2), so finger travel is rigidly 1:1. The asset gave the
solver a degree of freedom the robot does not have, which is why the jaws
could separate at all.
USD now authors PhysxMimicJointAPI on joint_right referencing joint_left, and
the MJCF authors the equivalent . Note PhysX constrains
q_follower + gearing * q_leader + offset = 0, so gearing = -1 is what makes the
two coordinates EQUAL -- the finger axes are already antiparallel in the parent
frame. Using +1 yields q_right = -q_left, which looks perfectly symmetric while
collapsing to the wrong opening; MuJoCo hides this and only running both
engines catches it.
The MJCF equality uses the standard positive solref form (0.004 1,
timeconst = 2*dt). Direct constraint gains (negative solref) couple ~11x
tighter in MuJoCo but make Newton's SolverMuJoCo diverge to NaN, and this
model is published for both engines.
The URDF's asymmetric finger limits are also corrected: joint_left allowed
0-0.05 m and joint_right 0-0.0715 m. Both fingers use the same mesh, their travel
axes are exactly antiparallel (dot product -1.000000) and their origins sit
0.147 mm apart, so the stroke is symmetric and 0.0715 was an authoring error --
it also made a 1:1 coupling impossible to express.
Pipeline repairs this depends on
--------------------------------
prep_asset.py could not run at all on main. #18 renamed the payload layers
(base.usda -> RS-rebot-dev-arm_base.usd, physics.usda ->
RS-rebot-dev-arm_physics.usd) without updating the script, so it died in its
first function with AttributeError: 'NoneType' object has no attribute
'GetPrimAtPath'. The five stale paths and the ./instances.usda re-anchor are
fixed, so the USD is regenerable again.
Physics=none was not neutral. The physics payload was attached to the root prim
as well as inside the variant, so it composed in every selection and the "no
physics" variant still carried 10 colliders and 10 rigid bodies -- the exact
condition prep_asset.py already asserted against, and a SimReady Robot-Body
Neutral requirement. Removing the duplicate root-prim payload leaves the variant
as the only opinion. Physics=none now composes zero colliders and zero rigid
bodies, while physics and mujoco keep their 10 (7 convexHull, 3
convexDecomposition).
The gain assertions compared floats with ==, which only held while every authored
gain was integral; USD stores them as float32, so 41.28 reads back as
41.279998779296875. They now compare with a tolerance. verify() additionally
asserts the mimic survives regeneration, so the coupling cannot be silently lost.
Validation
----------
Isaac Sim 6.1 (PhysX), headless, articulation loaded from the asset:
PhysxMimicJointAPI:Z composed, reference=joint_left, gearing=-1.0
static : finger dev 0.00006 mm asym 0.00011 mm
slewing : finger dev 0.00629 mm asym 0.01204 mm
MuJoCo, same experiment: dev 0.017 mm, asym 0.034 mm, neq=1.
validate_physics_fidelity.py: failures: [], 10 links and 8 joints checked across
the physics and mujoco variants, with the compiled-MJCF cross-check running
(mujoco 3.10.0) rather than skipped.
prep_asset.py: all three variants verify and re-running is a byte-level no-op.
build_mjcf.py regenerates rebot_devarm.xml byte-identical to the committed file.
Improvement, measured within each engine on the same experiment (upstream/main's
asset vs this one), arm sweeping joint1 +/-0.4 rad and joint2 +/-0.2 rad:
Isaac Sim 6.1 / PhysX MuJoCo 3.10
slew before after slew before after
0.25Hz 0.283mm 0.0064mm 0.25Hz 0.886mm 0.017mm
0.50Hz 0.580mm 0.0128mm 0.50Hz 1.900mm 0.033mm
1.00Hz 1.338mm 0.0305mm 1.00Hz 1.792mm 0.066mm
2.00Hz 3.428mm 0.0687mm 2.00Hz 2.159mm 0.147mm
4.00Hz 5.760mm 0.1353mm
8.00Hz 6.797mm 0.2526mm
Roughly 44x across the range in PhysX and 15-57x in MuJoCo. The fingers are not
motionless -- deviation grows with slew rate -- but the improvement holds at
every speed rather than only at gentle ones, and the degradation is gradual
with no instability threshold. At 8 Hz, well beyond any teleop rate, the
residual 0.25 mm is 0.5% of the 50 mm stroke; the unfixed asset exceeded 1% of
stroke by 0.5 Hz. Static gravity sag goes from 7.375 mm to ~0.
For reference the physical gripper, unpowered, reads 268.5 urad of encoder noise
(0.00197 mm at the finger), so the fixed asset sits 3x that floor at 0.25 Hz and
128x at 8 Hz.
Why the jaws move at all is documented in
usd/RS-rebot-dev-arm/docs/GRIPPER_RESIDUAL_MOTION.md, since a single motor holding
them is a reasonable reason to expect zero. In short: it is not drive compliance
(16x stiffer changes 0.1516 -> 0.1459 mm, and the actuator uses 0.88 N of 1904 N),
and it is not coupling softness (tightening solref past the shipped 0.004 changes
nothing). With the arm held still the deviation is 0.00000 mm; it appears only
under wrist acceleration, and gets worse when the ARM tracks better (0.147 mm at
shipped gains, 0.774 mm at 16x) because a stiffer arm accelerates the wrist
harder. The jaws have mass, so dx = m*a/K -- ordinary rigid-body mechanics.
The drive must ship at 5 kN/m rather than the motor's reflected 925 kN/m
(50 N*m/rad over a 7.353 mm/rad pinion) because the real value diverges at
dt = 1/120 s. At the real stiffness the same accelerations give 0.0001-0.0010 mm,
below the 0.00197 mm encoder noise floor -- so the physical gripper does not move
measurably, and the simulated residual is a timestep budget, not an asset
parameter. Deviation falls with dt (1/240 s, 1/480 s, 1/960 s measured), which is
the knob for anyone needing sub-0.01 mm fidelity.
The README gripper-limit tables are corrected alongside: they still documented
joint_right's 0.0715 m upper limit, which this change replaces with the measured
symmetric 0.05 m on both fingers.
Follow-up measurements on the physical arm (RobStride motor 7 over can0, MIT
mode, after writing and verifying the official MotorBridge Studio parameter
template) refine that note further, and refute two plausible fixes:
breakaway torque 0.100 N*m -> 13.6 N at the finger
closed-loop K 66,759 N/m at kp=3 N*m/rad (median of 12 plateaus,
64,800-69,900, 4% spread; theory kp/r^2 = 55,487, so 1.20x)
backlash +/-0.37 mm, both directions free from a closed stop
Real friction exceeds the inertial load on the jaws by 14-130x, so the physical
gripper cannot be shifted by wrist acceleration at all. Writing that friction
into the asset was tested and makes the simulation WORSE (2 Hz: 0.1466 ->
0.1832 mm), because in sim the joint is driven by the coupling constraint, and
friction cannot oppose a constraint -- it only makes the solver push harder.
Measuring the internal forces shows the constraint applies more force than the
actuator (1.207 N vs 0.877 N at 2 Hz) and accounts for 43% of the residual
(disabling it: 0.1466 -> 0.0840 mm). It is already saturated: solref from
0.004 down to 0.0002 gives an identical 0.1466 mm. Loosening solimp lowers the
number only by weakening the coupling, which is not a fix.
That leaves the integration rate, which converges cleanly as 1/dt from the
asset's native 500 Hz (0.1466 mm at 2 Hz) to 4000 Hz (0.0195 mm), with the 1:1
coupling exact (|left-right| = 0.00000 mm) at every rate.
Conclusion: no asset parameter changes. The mechanism model is correct, and the
residual is a documented solver floor rather than a defect. Only the note, its
evidence and the measurement scripts are added.
Three properties of third_party/reBotArm_control_py are documented alongside,
since they cost several failed runs and silently corrupt measurements:
* send_mit() is ignored unless ensure_mode(Mode.MIT) is called first. No
error is raised; measured torque simply never tracks the command (a 0.05 ->
2.0 N*m ramp produced a flat -0.13 N*m). run_mode is 0x7005, read as i8.
* send_pos_vel(pos, vlim) does not carry vlim in the command frame -- it
writes parameter 0x7017 (limit_spd). mode_pos_vel() likewise writes 0x7017,
0x701E, 0x701F and 0x7020 from the library YAML, overwriting the vendor's
calibrated per-joint template without warning.
* position-mode stall detection is not viable at the 40 Hz feedback rate: the
drive goes from free to hard stop within one sample, so a 1.0 N*m threshold
was never observed (consecutive samples read ~0 then -3.76 N*m).
Five scripts reproduce every table: measure_gripper_stiffness.py and
measure_gripper_breakaway.py need the arm (both take --dry-run, which exercises
the full path against live telemetry without enabling the motor);
sweep_gripper_friction.py, sweep_coupling_constraint.py and
sweep_timestep_convergence.py are simulation-only. All resolve their paths
relative to the repo and were verified from a clean working directory.
SimReady conformance
--------------------
Validated with the official simready-validate from NVIDIA/simready-foundation.
Robot-Body-Neutral now PASSES; it failed on upstream/main before this change.
Making Physics=none genuinely neutral had to be reconciled with SimReady
ISA.001, which requires the _physics.usd payload on the default prim in the
ROOT layer (isaac_sim/composition/validation.py reads prim_spec.payloadList
directly, so a payload authored inside a variant does not count). A root-prim
payload composes under every selection, so the two requirements pull opposite
ways. The variant now subtracts what the payload adds: inside Physics=none the
/Physics scope and the collision instances are deactivated and the body-level
physics API schemas are stripped. ISA.001 keeps its payload and the neutral
selection composes to plain geometry.
Three upstream problems were found while doing this and are reported in
NVIDIA/simready-foundation#18:
- DJ.001 crashes on any joint carrying PhysxMimicJointAPI: validation.py lines
258 and 535 call .Get() on a float that lines 256-257 already resolved. Only
reachable with a coupled mechanism, which is why it had gone unnoticed.
- DJ.007 only accepts rotational mimic axes (rotX/rotY/rotZ), so no linear
coupled gripper can satisfy it, even though UsdPhysics defines transX/Y/Z and
PhysX's own Newton migration checker documents prismatic mimic support.
- DJ.004 wants zero drive gains on a mimic follower. That is correct for a
revolute pair, but measurements in Isaac Sim show PhysX does not enforce
PhysxMimicJointAPI on a PRISMATIC joint at all -- removing the API entirely
gives numbers identical to four decimal places, and with zero gains the
fingers drift 51.5 mm, far worse than the 2.3 mm bug this fixes. The follower
therefore keeps its drive: Newton and MuJoCo honour the constraint, PhysX is
carried by the drive.
The PhysicsScene is no longer authored by the asset. RC.005 rejects physics
attributes authored in the interface layer, and SimReady's own reference robot
(sample_content/.../Robotiq/2F-85) ships without one. Three placements were
measured and none satisfies every constraint at once:
interface layer /PhysicsScene composes, fails RC.005
physics layer /PhysicsScene never composes (a payload arc maps
only the defaultPrim subtree)
physics layer /PhysicsScene composes and passes RC.005, but leaks
into any stage referencing the robot
Gravity belongs to the stage that loads the robot: Isaac Sim, Newton and MuJoCo
all supply it. verify() and validate_physics_fidelity.py now assert the asset
does NOT author a scene, so a future re-export cannot reintroduce a hardcoded
gravity that fights the host.
Three environment problems made earlier SimReady results unreliable, and are
worth knowing before re-running the validator:
- The stock validate venv installs usd-exchange, whose pxr does not ship
PhysxSchema. Every rule touching it reports "PhysxSchema is not available in
this environment" as a FAILURE rather than skipping -- 161 such messages here.
DJ.002 and DJ.003 were false negatives.
- RC.007 and DJ.008/009/010 import usd.schema.isaac, which ships with the Isaac
Sim build rather than the validator. Without it they abort with "No module
named 'usd.schema.isaac'" before inspecting the asset. All four pass once it
is on the path.
- RC.001 and RC.004 need omni.client; without it they raise "module 'omni' has
no attribute 'client'". Both still report that here, so their status is
unknown rather than failing: the asset root holds only the interface layer
(what RC.001 wants) and .thumbs/256x256/RS-rebot-dev-arm.usda.png exists
(what RC.004 wants).
---
README.md | 2 +-
README_EN.md | 2 +-
README_ES.md | 2 +-
mjcf/rebot_devarm/build_mjcf.py | 31 ++-
mjcf/rebot_devarm/rebot_devarm.xml | 9 +-
.../urdf/00-arm-rs_asm-v3.urdf | 10 +-
usd/RS-rebot-dev-arm/RS-rebot-dev-arm.usda | 120 +++++++++-
.../docs/GRIPPER_RESIDUAL_MOTION.md | 205 ++++++++++++++++
usd/RS-rebot-dev-arm/docs/VALIDATION.md | 7 +
usd/RS-rebot-dev-arm/evidence/breakaway.json | 6 +
.../evidence/compliance_analysis.json | 54 +++++
.../evidence/constraint_sweep.json | 142 +++++++++++
.../evidence/friction_sweep.json | 57 +++++
usd/RS-rebot-dev-arm/evidence/mechanism.json | 39 +++
.../evidence/mit_calibration.json | 105 ++++++++
.../evidence/residual_cause.json | 76 ++++++
usd/RS-rebot-dev-arm/evidence/slew_sweep.csv | 7 +
.../evidence/timestep_convergence.json | 50 ++++
.../payloads/RS-rebot-dev-arm_physics.usd | 41 ++--
.../scripts/measure_gripper_breakaway.py | 207 ++++++++++++++++
.../scripts/measure_gripper_stiffness.py | 225 ++++++++++++++++++
usd/RS-rebot-dev-arm/scripts/prep_asset.py | 210 +++++++++++++---
.../scripts/sweep_coupling_constraint.py | 142 +++++++++++
.../scripts/sweep_gripper_friction.py | 150 ++++++++++++
.../scripts/sweep_timestep_convergence.py | 129 ++++++++++
.../scripts/validate_physics_fidelity.py | 26 +-
26 files changed, 1974 insertions(+), 80 deletions(-)
create mode 100644 usd/RS-rebot-dev-arm/docs/GRIPPER_RESIDUAL_MOTION.md
create mode 100644 usd/RS-rebot-dev-arm/evidence/breakaway.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/compliance_analysis.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/constraint_sweep.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/friction_sweep.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/mechanism.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/mit_calibration.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/residual_cause.json
create mode 100644 usd/RS-rebot-dev-arm/evidence/slew_sweep.csv
create mode 100644 usd/RS-rebot-dev-arm/evidence/timestep_convergence.json
create mode 100644 usd/RS-rebot-dev-arm/scripts/measure_gripper_breakaway.py
create mode 100644 usd/RS-rebot-dev-arm/scripts/measure_gripper_stiffness.py
create mode 100644 usd/RS-rebot-dev-arm/scripts/sweep_coupling_constraint.py
create mode 100644 usd/RS-rebot-dev-arm/scripts/sweep_gripper_friction.py
create mode 100644 usd/RS-rebot-dev-arm/scripts/sweep_timestep_convergence.py
diff --git a/README.md b/README.md
index dc2f904..01e0468 100644
--- a/README.md
+++ b/README.md
@@ -221,7 +221,7 @@ UDP JSON,端口 `127.0.0.1:5005`。
| `gripper_position` | float | 夹爪指位置目标(m),各发送端有各自的换算方式(见下表) |
**夹爪控制链:**
-接收端将收到的 `gripper_position` 直接作为左右两个滑动关节的位置目标,并按各指裁剪到 `[0, 上限]`(USD 上限:`joint_left` 0.05 m,`joint_right` 0.0715 m)。接收端不做额外缩放。各发送端到 `gripper_position` 的换算如下:
+接收端将收到的 `gripper_position` 直接作为左右两个滑动关节的位置目标,并按各指裁剪到 `[0, 上限]`(USD 上限:两指均为 0.05 m;两指由同一电机通过单个小齿轮驱动,行程严格 1:1)。接收端不做额外缩放。各发送端到 `gripper_position` 的换算如下:
| 发送端 | 到 `gripper_position`(m)的换算 |
|------|------|
diff --git a/README_EN.md b/README_EN.md
index e9dca3b..51dbceb 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -221,7 +221,7 @@ UDP JSON on `127.0.0.1:5005`.
| `gripper_position` | float | Gripper finger position target (m); each sender computes it with its own mapping (see below) |
**Gripper control chain:**
-The receiver applies the received `gripper_position` directly as the position target of both prismatic finger joints, clipped per finger to `[0, upper limit]` (USD upper limits: `joint_left` 0.05 m, `joint_right` 0.0715 m). There is no extra scaling on the receiver side. The senders map their input to `gripper_position` as follows:
+The receiver applies the received `gripper_position` directly as the position target of both prismatic finger joints, clipped per finger to `[0, upper limit]` (USD upper limit: 0.05 m on both fingers; a single motor drives both through one pinion, so their travel is rigidly 1:1). There is no extra scaling on the receiver side. The senders map their input to `gripper_position` as follows:
| Sender | Mapping to `gripper_position` (m) |
|------|------|
diff --git a/README_ES.md b/README_ES.md
index 528d278..9a73d03 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -221,7 +221,7 @@ JSON sobre UDP en `127.0.0.1:5005`.
| `gripper_position` | float | Objetivo de posición de los dedos de la pinza (m); cada emisor lo calcula con su propia conversión (véase más abajo) |
**Cadena de control de la pinza:**
-El receptor aplica el `gripper_position` recibido directamente como objetivo de posición de las dos articulaciones prismáticas de los dedos, recortado por dedo a `[0, límite superior]` (límites superiores del USD: `joint_left` 0,05 m, `joint_right` 0,0715 m). El receptor no aplica ninguna escala adicional. Los emisores convierten su entrada a `gripper_position` de la siguiente manera:
+El receptor aplica el `gripper_position` recibido directamente como objetivo de posición de las dos articulaciones prismáticas de los dedos, recortado por dedo a `[0, límite superior]` (límite superior del USD: 0,05 m en ambos dedos; un solo motor mueve los dos a través de un único piñón, por lo que su recorrido es rígidamente 1:1). El receptor no aplica ninguna escala adicional. Los emisores convierten su entrada a `gripper_position` de la siguiente manera:
| Emisor | Conversión a `gripper_position` (m) |
|------|------|
diff --git a/mjcf/rebot_devarm/build_mjcf.py b/mjcf/rebot_devarm/build_mjcf.py
index 40e2e6b..03eb144 100644
--- a/mjcf/rebot_devarm/build_mjcf.py
+++ b/mjcf/rebot_devarm/build_mjcf.py
@@ -36,7 +36,15 @@
DYNAMICS = {
"rs06": ("5", "900", "60", "-36 36"),
"rs00": ("2", "120", "10", "-14 14"),
- "gripper": ("1", "100", "4", "-500 500"),
+ # Gripper gains come from the physical actuator, not from a tuned guess.
+ # Motor 7 (RobStride, limit_torque = 14 Nm read from firmware) drives both
+ # racks through one pinion of r = 7.353 mm/rad, so it presents
+ # K = kp_motor / r^2 = 50 / 0.007353^2 = 925 kN/m and 14 / 0.007353 = 1904 N
+ # at the finger. kp is capped at the stiffest value that stays stable at the
+ # solver step; kv gives a damping ratio of 1 against the 0.0752 kg finger.
+ # The previous (100, 4, 500) left the fingers at zeta = 0.685 -- underdamped,
+ # f_n = 5.45 Hz -- so they swung whenever the arm moved.
+ "gripper": ("1", "5000", "41.28", "-1904 1904"),
}
JOINT_CLASS = {
"joint1": "rs06",
@@ -271,6 +279,27 @@ def build(self):
}
ET.SubElement(actuator, "position", attrs)
+ # The two fingers are one mechanism, not two: a single motor drives two
+ # opposed racks through one pinion (hardware BOM: 02_Rack.step x2), so
+ # their travel is rigidly 1:1. Modelling them as independent prismatic
+ # joints let the jaws drift apart whenever the arm accelerated.
+ # solref uses the standard positive (timeconst, dampratio) form with
+ # timeconst = 2*dt. Direct constraint gains (negative solref) couple
+ # ~11x tighter in MuJoCo but make Newton's SolverMuJoCo diverge to NaN,
+ # and this model is published for both engines.
+ equality = ET.SubElement(root, "equality")
+ ET.SubElement(
+ equality,
+ "joint",
+ {
+ "joint1": "joint_left",
+ "joint2": "joint_right",
+ "polycoef": "0 1 0 0 0",
+ "solref": "0.004 1",
+ "solimp": "0.9999 0.99999 0.001 0.5 2",
+ },
+ )
+
keyframe = ET.SubElement(root, "keyframe")
for name, qpos in KEYFRAMES:
ET.SubElement(keyframe, "key", {"name": name, "qpos": qpos, "ctrl": qpos})
diff --git a/mjcf/rebot_devarm/rebot_devarm.xml b/mjcf/rebot_devarm/rebot_devarm.xml
index dec1360..cb85eb2 100644
--- a/mjcf/rebot_devarm/rebot_devarm.xml
+++ b/mjcf/rebot_devarm/rebot_devarm.xml
@@ -18,7 +18,7 @@
-
+
@@ -74,7 +74,7 @@
-
+