Gravity + inertial compensation — where we actually stand
Status: investigation only, nothing changed. Deliberately not folded into the RFC yet.
Goal: know what compensation the stack really performs before deciding whether it deserves a phase of its own.
Context and links
- RFC: Force-Based Movements — mine,
DRAFT, approved by Lou Graniou 2026-08-06. The RFC currently says, under Scope:
Gravity and inertia. We do not model these forces. We remove them by measurement.
- The feedback is an unresolved inline comment by Michelle ([email protected]) on that
exact line, 2026-08-05:
this is required to do actually good force sensing and one of the most serious drawbacks of our system right now. Direct link: https://app.notion.com/p/37b1b2bda0bb81b0b2c0ce4148694f62?d=3b31b2bda0bb80b7b57c001ce0559e53&pvs=42#656d2183034648d7b603d5dc3b9bd268
- Phase 0 in the RFC’s Implementation Roadmap is currently just “basic F/T collection scripts merged”. The open question is whether to widen it to include gravity/inertia characterization.
- [WIP] RFC: Hand-Guided Calibration
— Aarjav’s admittance-mode doc, branch
aarjavjain/handguid-experiment. Read this before writing anything. Its Step 0 is already built and sim-validated and measures precisely the residual in question (scripts/data_collection/ft_orientation_sweep.py+_ft_capture.py, built on my mergedsaahas/ft-experiments/ PR #10460). Two other unresolved comments on my RFC, both from Lou: deck-wide time sync is out of scope but quantifying clock skew is worth doing, and “are we accounting for all 6 DOF of the EEF or a subset?” on the retry-policy section.
TL;DR
The RC8 compensates the arm well and our URDFs have nothing to do with it. The tool is compensated through a two-entry table selected by an integer, and a grasped plate/tip-rack/bottle is not compensated at all. The knob we want (per-payload mass + COG + auto-measured MOI) already exists on the controller; we use two of its slots.
1. What the robot does
Arm self-weight and inertia: handled by the RC8’s own model. Our URDFs are not in that loop. Evidence it does inertia and not merely gravity: the controller exposes a payload moment of inertia setting distinct from mass and COG. A gravity-only controller needs mass + COG and nothing more — an MOI field only earns its place in an inertia feedforward term. (Inference, not read from an official Denso manual.)
Tool: one integer wide.
// medra_bcap/src/DensoController.cpp:413
BCAP_HRESULT DensoReadWriteDriver::SetTcpLoad(const int32_t tool_value)
// tool_value = 1; // ROBOTIQ_2F85_GRIPPER_PAYLOAD
// tool_value = 2; // ROBOTIQ_2F140_GRIPPER_PAYLOAD
bCap_RobotGetVariable(iSockFD, lhRobot, "@CURRENT_TOOL", "", &lhVar);TCPPayload(mass=1.025, com=(0,0,73)) at medra_robotics/arm_control/robot_constants.py:154 is
never transmitted. It records what somebody typed into a pendant once — per robot, unverified
across the fleet, unverifiable from code. Two slots, both gripper-only.
Grasped objects: not compensated. Nothing re-registers a payload on grasp. The cost is already in the tree:
# medra_robotics/task_execution/behaviors/bottle_behaviors.py:252
# The grasped 45deg tool + bottle weight is not gravity-compensated (the bottle
# Grasp passes no TCP payload), so that uncompensated hanging load shows up as a
# standing Z force. A 10 N limit trips "contact" from gravity alone...10 N → 30 N to absorb a standing load, i.e. a 3× loss of contact sensitivity. Against the sigma ≈ 0.2–0.35 N noise floor that is ~100 sigma of headroom given away to a computable term.
F/T is model-compensated and re-tared — and there is no load cell. Confirmed by hardware
inspection (hand-guide RFC, 2026-06-27): there is no external wrist F/T puck. forceValue mode
13 is the RC9’s estimate of the external TCP wrench, computed from drive/joint torque plus the
controller’s own dynamic model, already gravity-compensated through the registered payload. On
top of that, ForceSensor("0") re-tares before each force-limited servo trajectory
(DensoController.cpp:610), which zeroes the remaining bias in the tare pose only — as the tool
reorients, the residual rotates in the sensor frame and the cancellation decays like
|g| m (1 - cos dtheta). Fine for a straight tool-z approach, wrong for the 45 deg bottle.
Two consequences that sharpen everything below:
- Do not subtract gravity in software. The controller already does it; a second subtraction double-compensates. What is worth measuring is the residual of the controller’s compensation.
- The grasped-object gap is not a missing layer — it is a wrong number inside an existing one. The RC8/RC9 is compensating for whichever of two gripper-only payloads is registered while the arm carries a plate. That is a model error we feed the controller, not an absent model.
A motor/model-based estimate is also noisier and more friction- and hysteresis-prone than a load cell, which is consistent with the measured sigma ~ 0.2–0.4 N and the 3–5x per-deck spread. So the sensor we would be “fixing” with a better payload model is itself the weak link — worth sizing before promising much.
The knob already exists. RC8 [Easy Setting] -> Payload stores mass (integer grams), COG
vector, and moment of inertia, and MOI has an [Automatic measure] routine that identifies it by
moving the arm. The table is per-tool-number, so “gripper + plate” and “gripper + tip rack” can each
be their own entry, selected on grasp. That is config plus a set_tcp_load call — not a new
dynamics layer.
2. What the URDFs contain (and one thing we shipped wrong)
Every moving link has <inertial> with mass, COM, and an inertia tensor. Three gaps:
- Diagonal-only inertias on all six Cobotta links —
ixy = ixz = iyz = 0. That asserts each link’s COM frame is its principal frame, true only under two planes of mirror symmetry through the COM. A cast link with an offset motor and cable routing doesn’t have them. xArm6’s URDF does carry off-diagonals, so this isn’t our house convention — the Cobotta numbers were transcribed from a datasheet table, not exported from CAD. Harmless for gravity torque (needs only m, COM); wrong for anything withomega_dotoromega x I omega. link_baseis a placeholder — m = 1, I = identity (assets/urdf/cobotta_pro/cobotta_pro.urdf:26). Contributes zero generalized torque (fixed to world), but shows nobody has audited the file.- Nothing past link6, no payload anywhere.
end_effectorandtool_framehave no<inertial>. No tip rack, plate, lid, bottle, or pipette appears in any URDF.
The shipped error: assets/curobo/cobotta_pro/cobotta_pro_2f140.urdf’s gripper chain is MuJoCo
Menagerie’s 2F-85 numbers, rounded:
| link | ours | Menagerie 2f85.xml |
|---|---|---|
| gripper_base_link | 0.777 | 0.777441 |
| driver | 0.009 | 0.00899563 |
| coupler | 0.014 | 0.0140974 |
| spring_link | 0.022 | 0.0221642 |
| follower | 0.013 | 0.0125222 |
| finger | 0.01, I = 1e-6 * identity | pad 0.0035 |
Menagerie stores inertia as (principal diaginertia + quat to the principal frame). The
conversion kept the diaginertia and dropped the quat — which is the claim that the COM frame is
principal-aligned. That is where the off-diagonals went. Sum: 0.913 kg against the 2F-140 datasheet
1.025 kg, and it is the wrong gripper’s linkage.
Nothing consumes these for dynamics: cuRobo reads the URDF for kinematics + collision only, and
MuJoCo scene objects get a hardcoded 0.2 kg / fixed diaginertia
(simulation/urdf_tools/mjcf_helpers.py:241).
3. Starting point for the gripper mass matrix
Two sources, each incomplete in a different way:
- ROS-Industrial
robotiq_2f_140_gripper_visualization— correct 140 linkage, full off-diagonal tensors. Sums to 0.364 kg because itsbase_link(0.2265) is the shell with no drive unit. Take its finger chain: per side outer_knuckle 0.00853, outer_finger 0.02261, inner_finger 0.01040, inner_knuckle 0.02712. - Menagerie
2f85— sums to 0.900 against a 0.925 datasheet, so its base (0.777) is a complete drive unit. The 85 and 140 share that body; the ~0.1 kg delta is the longer linkage.
Composite: 0.777 + 2(0.0687) = 0.914 kg, 0.111 short of datasheet. Scale all masses by 1.025/0.914 = 1.12 — uniform scaling leaves the COM exactly where it is (the only thing gravity comp reads) and scales I linearly. Alternative is dumping the residual in the base if you believe it is fasteners and cable at the mount. It is ±0.1 kg on a 1 kg tool, and the RC8 auto-measure moots the argument for the MOI half anyway.
Custom fingers
assets/curobo/cobotta_pro/meshes/gripper_finger.STL is watertight, so trimesh (already a dep)
gives it up directly. Per unit mass, about its own COM (at y = -5.80 mm, z = 18.52 mm, mesh frame):
I/m = diag(1.148e-4, 1.541e-4, 3.978e-5) m^2 V = 1.303 cm^3
Mass is then a single scalar knob — weigh a finger and multiply. At 6061 (2700 kg/m^3): 3.5 g, giving I = diag(4.0e-7, 5.4e-7, 1.4e-7) kg m^2.
Reproduce with:
import trimesh
m = trimesh.load('medra_robotics/assets/curobo/cobotta_pro/meshes/gripper_finger.STL')
m.density = 1.0 # unit density -> per-kg quantities
print(m.center_mass, m.moment_inertia / m.mass)Do not trust that 3.5 g. 22 x 1.6 x 37.5 mm is a flat blade, i.e. a collision proxy rather than
a machined finger — its computed diagonals match the uniform-plate (b^2 + c^2)/12 formula to three
digits, and 3.5 g is suspiciously exactly Menagerie’s pad mass. Off-diagonals came out at 1e-16
relative, so for this geometry diagonal-only is honest; that will not survive the real part.
Next steps (in this order)
- Talk to Aarjav before writing any characterization code. The hand-guide RFC’s Step 0 sweep
(orientation-diverse static poses, dwell-read over a second read-only b-CAP connection, Welford
mean+std, gravity direction from FK) already measures the residual, is sim-validated, and is
built on my own
ft-experimentstooling. If Phase 0 grows to cover gravity/inertia, it should adopt that script rather than grow a parallel one. Their pending item is the fit/analysis plus a real-deck run — that is the piece to pick up. - Weigh the gripper with the real custom fingers mounted. One number settles the 0.111 kg that no URDF will.
- Enter mass + COG in an RC8 payload slot, run [Automatic measure] for MOI. Controller-identified beats any CAD tensor, and it is the only path that buys inertia comp rather than gravity comp. Needs a human at the pendant with the deadman switch.
- Register per-grasp payload entries (gripper+plate, gripper+tip rack, gripper+bottle) and call
set_tcp_loadon grasp. This is the actual fix for the bottle 30 N workaround. - Fix the URDFs last, as documentation of what is in the controller. Nothing reads them for dynamics, so starting there buys nothing.
Open question worth answering before step 3: whether we want a write path from TCPPayload to the
controller (b-CAP Payload command takes grams + a COG vector) so the Python constants stop being
fiction, or whether the pendant stays the source of truth and Python just selects indices.
Sources
- ROS-I 2F-140 URDF — https://github.com/ros-industrial-attic/robotiq/blob/kinetic-devel/robotiq_2f_140_gripper_visualization/urdf/robotiq_arg2f_140_model_macro.xacro
- MuJoCo Menagerie 2f85 — https://github.com/google-deepmind/mujoco_menagerie/tree/main/robotiq_2f85
- RC8 auto-measure payload MOI — http://eidtech.dyndns-at-work.com/support/RC8_Manual/003062.html
- RC8 Payload command — http://eidtech.dyndns-at-work.com/support/RC8_Manual_OLD/000216.html
- Robotiq 2F-85/2F-140 manual — https://assets.robotiq.com/website-assets/support_documents/document/2F-85_2F-140_UR_PDF_20210623.pdf