Add tendon armature to MJX.

PiperOrigin-RevId: 769294284
Change-Id: Idfbd4f355eb26fba5035973ab989a9d3af05c85f
This commit is contained in:
Erik Frey
2025-06-09 14:26:18 -07:00
committed by Copybara-Service
parent b8768aa1cd
commit caaf7b3a69
6 changed files with 250 additions and 0 deletions
+4
View File
@@ -36,6 +36,10 @@ Python bindings
- Added examples of procedural terrain generation to the Model Editing tutorial: |mjspec_colab| - Added examples of procedural terrain generation to the Model Editing tutorial: |mjspec_colab|
MJX
^^^
- Added tendon armature.
Version 3.3.2 (April 28, 2025) Version 3.3.2 (April 28, 2025)
------------------------------ ------------------------------
+2
View File
@@ -48,6 +48,8 @@ from mujoco.mjx._src.smooth import rne
from mujoco.mjx._src.smooth import rne_postconstraint from mujoco.mjx._src.smooth import rne_postconstraint
from mujoco.mjx._src.smooth import subtree_vel from mujoco.mjx._src.smooth import subtree_vel
from mujoco.mjx._src.smooth import tendon from mujoco.mjx._src.smooth import tendon
from mujoco.mjx._src.smooth import tendon_armature
from mujoco.mjx._src.smooth import tendon_bias
from mujoco.mjx._src.smooth import transmission from mujoco.mjx._src.smooth import transmission
from mujoco.mjx._src.solver import solve from mujoco.mjx._src.solver import solve
from mujoco.mjx._src.support import apply_ft from mujoco.mjx._src.support import apply_ft
+2
View File
@@ -73,6 +73,7 @@ def fwd_position(m: Model, d: Data) -> Data:
d = smooth.camlight(m, d) d = smooth.camlight(m, d)
d = smooth.tendon(m, d) d = smooth.tendon(m, d)
d = smooth.crb(m, d) d = smooth.crb(m, d)
d = smooth.tendon_armature(m, d)
d = smooth.factor_m(m, d) d = smooth.factor_m(m, d)
d = collision_driver.collision(m, d) d = collision_driver.collision(m, d)
d = constraint.make_constraint(m, d) d = constraint.make_constraint(m, d)
@@ -93,6 +94,7 @@ def fwd_velocity(m: Model, d: Data) -> Data:
d = smooth.com_vel(m, d) d = smooth.com_vel(m, d)
d = passive.passive(m, d) d = passive.passive(m, d)
d = smooth.rne(m, d) d = smooth.rne(m, d)
d = smooth.tendon_bias(m, d)
return d return d
+152
View File
@@ -1184,3 +1184,155 @@ def transmission(m: Model, d: Data) -> Data:
{'_impl.actuator_length': length, '_impl.actuator_moment': moment} {'_impl.actuator_length': length, '_impl.actuator_moment': moment}
) )
return d return d
def tendon_armature(m: Model, d: Data) -> Data:
"""Add tendon armature to qM."""
if not isinstance(m._impl, ModelJAX) or not isinstance(d._impl, DataJAX):
raise ValueError('tendon_armature requires JAX backend implementation.')
if not support.is_sparse(m):
return d.tree_replace({
'_impl.qM': (
d._impl.qM
+ d._impl.ten_J.T
@ jax.vmap(jp.multiply)(d._impl.ten_J, m.tendon_armature)
)
})
else:
# TODO(taylorhowell): implement tendon armature with sparse qM
raise NotImplementedError(
'Tendon armature with sparse qM is not implemented.'
)
def tendon_dot(m: Model, d: Data) -> jax.Array:
"""Compute time derivative of dense tendon Jacobian for one tendon."""
if not isinstance(m._impl, ModelJAX) or not isinstance(d._impl, DataJAX):
raise ValueError('tendon_dot requires JAX backend implementation.')
ten_Jdot = jp.zeros((m.ntendon, m.nv)) # pylint: disable=invalid-name
if not m.ntendon:
return ten_Jdot
# process pulleys
(wrap_id_pulley,) = np.nonzero(m.wrap_type == WrapType.PULLEY)
divisor = np.ones(m.nwrap)
for adr, num in zip(m.tendon_adr, m.tendon_num):
for id_pulley in wrap_id_pulley:
if adr <= id_pulley < adr + num:
divisor[id_pulley : adr + num] = np.maximum(
mujoco.mjMINVAL, m.wrap_prm[id_pulley]
)
# process spatial tendon sites
(wrap_id_site,) = np.nonzero(m.wrap_type == WrapType.SITE)
# find consecutive sites, skipping tendon transitions
(pair_id,) = np.nonzero(np.diff(wrap_id_site) == 1)
wrap_id_site_pair = np.setdiff1d(wrap_id_site[pair_id], m.tendon_adr[1:] - 1)
wrap_objid_site0 = m.wrap_objid[wrap_id_site_pair]
wrap_objid_site1 = m.wrap_objid[wrap_id_site_pair + 1]
site_bodyid0 = m.site_bodyid[wrap_objid_site0]
site_bodyid1 = m.site_bodyid[wrap_objid_site1]
site_xpos0 = d.site_xpos[wrap_objid_site0]
site_xpos1 = d.site_xpos[wrap_objid_site1]
subtree_com0 = d.subtree_com[m.body_rootid[site_bodyid0]]
subtree_com1 = d.subtree_com[m.body_rootid[site_bodyid1]]
site_vel0 = jax.vmap(lambda a, b: a[3:] - jp.cross(b, a[:3]))(
d.cvel[site_bodyid0], site_xpos0 - subtree_com0
)
site_vel1 = jax.vmap(lambda a, b: a[3:] - jp.cross(b, a[:3]))(
d.cvel[site_bodyid1], site_xpos1 - subtree_com1
)
@jax.vmap
def _momentdot(wpnt0, wpnt1, wvel0, wvel1, body0, body1):
# dpnt = 3D position difference, normalize
dpnt = wpnt1 - wpnt0
norm = math.norm(dpnt)
dpnt = jp.where(
norm < mujoco.mjMINVAL, jp.array([1.0, 0.0, 0.0]), dpnt / norm
)
# dvel = d / dt(dpnt)
dvel = wvel1 - wvel0
dot = jp.dot(dpnt, dvel)
dvel += dpnt * -dot
dvel = jp.where(norm > mujoco.mjMINVAL, dvel / norm, 0.0)
# get endpoint JacobianDots, subtract
jacp1, _ = support.jac_dot(m, d, wpnt0, body0)
jacp2, _ = support.jac_dot(m, d, wpnt1, body1)
jacdif = jacp2 - jacp1
# chain rule, first term: Jdot += d / dt(jac2 - jac1) * dpnt
tmp0 = jacdif @ dpnt
# get endpoint Jacobians, subtract
jacp1, _ = support.jac(m, d, wpnt0, body0)
jacp2, _ = support.jac(m, d, wpnt1, body1)
jacdif = jacp2 - jacp1
# chain rule, second term: Jdot += (jac2 - jac1) * d/dt (dpnt)
tmp1 = jacdif @ dvel
return jp.where(body0 != body1, tmp0 + tmp1, jp.zeros(m.nv))
momentdots = _momentdot(
site_xpos0,
site_xpos1,
site_vel0,
site_vel1,
site_bodyid0,
site_bodyid1,
)
if wrap_id_site_pair.size:
divisor_site_pair = divisor[wrap_id_site_pair]
momentdots /= divisor_site_pair[:, None]
tendon_nsite = np.array([
sum((wrap_id_site_pair >= adr) & (wrap_id_site_pair < adr + num))
for adr, num in zip(m.tendon_adr, m.tendon_num)
])
tendon_has_site = tendon_nsite > 0
(tendon_id_site,) = np.nonzero(tendon_has_site)
tendon_nsite = tendon_nsite[tendon_has_site]
tendon_with_site = tendon_nsite.size
ten_site_id = np.repeat(np.arange(tendon_with_site), tendon_nsite)
momentdot = jax.ops.segment_sum(momentdots, ten_site_id, tendon_with_site)
ten_Jdot = ten_Jdot.at[tendon_id_site].set(momentdot) # pylint: disable=invalid-name
# TODO(taylorhowell): time derivatives for geoms
return ten_Jdot
def tendon_bias(m: Model, d: Data) -> Data:
"""Add bias force due to tendon armature."""
if not isinstance(m._impl, ModelJAX) or not isinstance(d._impl, DataJAX):
raise ValueError('tendon_bias requires JAX backend implementation.')
if not m.ntendon:
return d
# get dense d/dt(tendon Jacobian) for each tendon
ten_Jdot = tendon_dot(m, d) # pylint: disable=invalid-name
# add bias term: qfrc += ten_J * armature * ten_Jdot @ qvel
coef = m.tendon_armature * jp.dot(ten_Jdot, d.qvel)
if not support.is_sparse(m):
return d.tree_replace({
'qfrc_bias': (
d.qfrc_bias
+ jp.sum(jax.vmap(jp.multiply)(d._impl.ten_J, coef), axis=0)
)
})
else:
# TODO(taylorhowell): implement tendon bias with sparse qM
raise NotImplementedError('Tendon bias with sparse qM is not implemented.')
+53
View File
@@ -17,6 +17,7 @@
from absl.testing import absltest from absl.testing import absltest
from absl.testing import parameterized from absl.testing import parameterized
import jax import jax
from jax import numpy as jp
import mujoco import mujoco
from mujoco import mjx from mujoco import mjx
from mujoco.mjx._src import test_util from mujoco.mjx._src import test_util
@@ -316,6 +317,58 @@ class TendonTest(parameterized.TestCase):
_assert_eq(d.wrap_obj, dx._impl.wrap_obj, 'wrap_obj') _assert_eq(d.wrap_obj, dx._impl.wrap_obj, 'wrap_obj')
_assert_eq(d.wrap_xpos, dx._impl.wrap_xpos, 'wrap_xpos') _assert_eq(d.wrap_xpos, dx._impl.wrap_xpos, 'wrap_xpos')
def test_tendon_armature(self):
"""Tests MJX tendon armature matches MuJoCo."""
m = mujoco.MjModel.from_xml_string("""
<mujoco>
<worldbody>
<site name="site0" pos="1 0 1"/>
<body>
<joint type="slide" axis="0 0 1"/>
<joint type="hinge" axis="0 1 0"/>
<geom type="box" size="0.1 0.1 0.1" mass="1" pos="1 0 0"/>
<site name="site1"/>
</body>
</worldbody>
<tendon>
<spatial armature="123">
<site site="site0"/>
<site site="site1"/>
</spatial>
<spatial armature="456">
<site site="site0"/>
<site site="site1"/>
</spatial>
</tendon>
<keyframe>
<key qpos="1.2345 1.2345" qvel="1.2345 1.2345"/>
</keyframe>
</mujoco>
""")
d = mujoco.MjData(m)
mujoco.mj_resetDataKeyframe(m, d, 0)
mujoco.mj_forward(m, d)
qM = np.zeros((m.nv, m.nv)) # pylint: disable=invalid-name
mujoco.mj_fullM(m, qM, d.qM)
mx = mjx.put_model(m)
dx = mjx.put_data(m, d)
dx = dx.tree_replace(
{'_impl.qM': jp.zeros((m.nv, m.nv)), 'qfrc_bias': jp.zeros(m.nv)}
)
dx = mjx.crb(mx, dx)
dx = mjx.tendon_armature(mx, dx)
_assert_eq(dx._impl.qM, qM, 'qM')
dx = mjx.rne(mx, dx)
dx = mjx.tendon_bias(mx, dx)
_assert_eq(dx.qfrc_bias, d.qfrc_bias, 'qfrc_bias')
if __name__ == '__main__': if __name__ == '__main__':
absltest.main() absltest.main()
+37
View File
@@ -142,6 +142,7 @@ def jac(
m: Model, d: Data, point: jax.Array, body_id: jax.Array m: Model, d: Data, point: jax.Array, body_id: jax.Array
) -> Tuple[jax.Array, jax.Array]: ) -> Tuple[jax.Array, jax.Array]:
"""Compute pair of (NV, 3) Jacobians of global point attached to body.""" """Compute pair of (NV, 3) Jacobians of global point attached to body."""
# TODO(taylorhowell): statically construct mask
fn = lambda carry, b: b if carry is None else b + carry fn = lambda carry, b: b if carry is None else b + carry
mask = (jp.arange(m.nbody) == body_id) * 1 mask = (jp.arange(m.nbody) == body_id) * 1
mask = scan.body_tree(m, fn, 'b', 'b', mask, reverse=True) mask = scan.body_tree(m, fn, 'b', 'b', mask, reverse=True)
@@ -155,6 +156,42 @@ def jac(
return jacp, jacr return jacp, jacr
def jac_dot(
m: Model, d: Data, point: jax.Array, body_id: jax.Array
) -> Tuple[jax.Array, jax.Array]:
"""Compute pair of (NV, 3) Jacobian time derivatives of global point attached to body."""
# TODO(taylorhowell): statically construct mask
fn = lambda carry, b: b if carry is None else b + carry
mask = (jp.arange(m.nbody) == body_id) * 1
mask = scan.body_tree(m, fn, 'b', 'b', mask, reverse=True)
mask = mask[jp.array(m.dof_bodyid)] > 0
offset = point - d.subtree_com[jp.array(m.body_rootid)[body_id]]
pvel_lin = d.cvel[body_id][3:] - jp.cross(offset, d.cvel[body_id][:3])
cdof = d._impl.cdof
cdof_dot = d._impl.cdof_dot
# check for quaternion
jnt_type = m.jnt_type[m.dof_jntid]
dof_adr = m.jnt_dofadr[m.dof_jntid]
is_quat = (jnt_type == JointType.BALL) | (
jnt_type == JointType.FREE & (np.arange(m.nv) >= dof_adr + 3)
)
# compute cdof_dot for quaternion (use current body cvel)
cdof_dot_quat = jax.vmap(math.motion_cross)(d.cvel[m.dof_bodyid], cdof)
cdof_dot = jp.where(is_quat[:, None], cdof_dot_quat, cdof_dot)
jacp = jax.vmap(
lambda a, b: a[3:] + jp.cross(a[:3], offset) + jp.cross(b[:3], pvel_lin)
)(cdof_dot, cdof)
jacp = jax.vmap(jp.multiply)(jacp, mask)
jacr = jax.vmap(jp.multiply)(cdof_dot[:, :3], mask) # pytype: disable=attribute-error
return jacp, jacr
def apply_ft( def apply_ft(
m: Model, m: Model,
d: Data, d: Data,