3ec09f7296
PiperOrigin-RevId: 868229512 Change-Id: I790bc08fc8b0745583a2f92d9ee2c5a19ba558ea
544 lines
16 KiB
Python
544 lines
16 KiB
Python
# Copyright 2026 DeepMind Technologies Limited
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
# ==============================================================================
|
||
|
||
"""Model modifiers."""
|
||
|
||
from typing import Any
|
||
|
||
import mujoco
|
||
from mujoco.sysid._src.parameter import InertiaType
|
||
from mujoco.sysid._src.parameter import ModifierFn
|
||
from mujoco.sysid._src.parameter import Parameter
|
||
from mujoco.sysid._src.parameter import ParameterDict
|
||
import numpy as np
|
||
|
||
|
||
def remove_visuals(in_spec: mujoco.MjSpec) -> mujoco.MjSpec:
|
||
"""Remove visual elements from a Spec."""
|
||
spec = in_spec.copy()
|
||
all_geoms = spec.worldbody.find_all("geom")
|
||
for geom in all_geoms:
|
||
if geom.contype == 0 and geom.conaffinity == 0:
|
||
if geom.type == mujoco.mjtGeom.mjGEOM_MESH and geom.meshname:
|
||
meshname = geom.meshname
|
||
mesh = spec.mesh(meshname)
|
||
if mesh: # multiple geoms can ref same mesh.
|
||
spec.delete(mesh)
|
||
spec.delete(geom)
|
||
|
||
for mat in spec.materials:
|
||
spec.delete(mat)
|
||
for tex in spec.textures:
|
||
spec.delete(tex)
|
||
|
||
spec.compile() # TODO(b/0): is this compile necessary?
|
||
return spec
|
||
|
||
|
||
def _get_obj_or_raise(spec: mujoco.MjSpec, obj_type: str, obj_name: str) -> Any:
|
||
getter = getattr(spec, obj_type, None)
|
||
if not callable(getter):
|
||
raise AttributeError(f"MjSpec has no method '{obj_type}'")
|
||
obj = getter(obj_name)
|
||
if obj is None:
|
||
raise ValueError(f"{obj_type.capitalize()} '{obj_name}' not found in spec.")
|
||
return obj
|
||
|
||
|
||
def apply_param_modifiers_spec(
|
||
params: ParameterDict, spec: mujoco.MjSpec
|
||
) -> mujoco.MjSpec:
|
||
for key in params.keys():
|
||
param = params[key]
|
||
if not param.frozen:
|
||
param.apply_modifier(spec)
|
||
return spec
|
||
|
||
|
||
def apply_param_modifiers(
|
||
params: ParameterDict, spec: mujoco.MjSpec
|
||
) -> mujoco.MjModel:
|
||
return apply_param_modifiers_spec(params, spec).compile()
|
||
|
||
|
||
def _infer_inertial(spec: mujoco.MjSpec, body_name: str) -> mujoco.MjsBody:
|
||
"""Override spec inertia using inferred inertia from compiled model."""
|
||
body = _get_obj_or_raise(spec, "body", body_name)
|
||
assert isinstance(body, mujoco.MjsBody)
|
||
spec.compiler.inertiafromgeom = 2
|
||
model = spec.compile()
|
||
body.explicitinertial = True
|
||
body.fullinertia = np.full((6, 1), np.nan)
|
||
body.mass = model.body(body_name).mass[0]
|
||
body.inertia = model.body(body_name).inertia
|
||
body.ipos = model.body(body_name).ipos
|
||
body.iquat = model.body(body_name).iquat
|
||
return body
|
||
|
||
|
||
def is_position_actuator(actuator) -> bool:
|
||
"""Check if an actuator is a position actuator.
|
||
|
||
This function works on both model.actuator and spec.actuator objects.
|
||
|
||
Args:
|
||
actuator: An actuator object from model or spec.
|
||
|
||
Returns:
|
||
True if the actuator is a position actuator.
|
||
"""
|
||
return (
|
||
actuator.gaintype == mujoco.mjtGain.mjGAIN_FIXED
|
||
and actuator.biastype == mujoco.mjtBias.mjBIAS_AFFINE
|
||
and actuator.dyntype
|
||
in (mujoco.mjtDyn.mjDYN_NONE, mujoco.mjtDyn.mjDYN_FILTEREXACT)
|
||
and actuator.gainprm[0] == -actuator.biasprm[1]
|
||
)
|
||
|
||
|
||
def get_actuator_pd_gains(
|
||
model: mujoco.MjModel, actuator_name: str
|
||
) -> tuple[float, float]:
|
||
"""Return the (P, D) gains of a position actuator."""
|
||
actuator_id = mujoco.mj_name2id(
|
||
model, mujoco.mjtObj.mjOBJ_ACTUATOR.value, actuator_name
|
||
)
|
||
if actuator_id == -1:
|
||
raise ValueError(f"Actuator {actuator_name} not found in model.")
|
||
actuator = model.actuator(actuator_id)
|
||
if not is_position_actuator(actuator):
|
||
raise ValueError(f"Actuator {actuator_name} is not a position actuator.")
|
||
return -actuator.biasprm[1], -actuator.biasprm[2]
|
||
|
||
|
||
def apply_pgain(
|
||
spec: mujoco.MjSpec,
|
||
actuator_name: str,
|
||
value: float | np.ndarray,
|
||
) -> mujoco.MjSpec:
|
||
"""Set the proportional gain for a position actuator."""
|
||
# TODO(b/0): assert scalar
|
||
actuator = _get_obj_or_raise(spec, "actuator", actuator_name)
|
||
assert isinstance(actuator, mujoco.MjsActuator)
|
||
if not is_position_actuator(actuator):
|
||
raise ValueError(f"Actuator {actuator_name} is not a position actuator.")
|
||
actuator.gainprm[0] = value
|
||
actuator.biasprm[1] = -value
|
||
return spec
|
||
|
||
|
||
def apply_dgain(
|
||
spec: mujoco.MjSpec,
|
||
actuator_name: str,
|
||
value: float | np.ndarray,
|
||
) -> mujoco.MjSpec:
|
||
"""Set the derivative gain for a position actuator."""
|
||
# TODO(b/0): assert scalar
|
||
actuator = _get_obj_or_raise(spec, "actuator", actuator_name)
|
||
assert isinstance(actuator, mujoco.MjsActuator)
|
||
if not is_position_actuator(actuator):
|
||
raise ValueError(f"Actuator {actuator_name} is not a position actuator.")
|
||
actuator.biasprm[2] = -value
|
||
return spec
|
||
|
||
|
||
def apply_pdgain(
|
||
spec: mujoco.MjSpec,
|
||
actuator_name: str,
|
||
value: np.ndarray,
|
||
) -> mujoco.MjSpec:
|
||
"""Set both proportional and derivative gains for a position actuator."""
|
||
if value.size != 2:
|
||
raise ValueError(f"pdgain must be a 2-element array, got {value.size}.")
|
||
apply_pgain(spec, actuator_name, value[0])
|
||
apply_dgain(spec, actuator_name, value[1])
|
||
return spec
|
||
|
||
|
||
def apply_body_mass_ipos(
|
||
spec: mujoco.MjSpec,
|
||
body_name: str,
|
||
mass: np.ndarray | None = None,
|
||
ipos: np.ndarray | None = None,
|
||
rot_inertia_scale: bool = False,
|
||
) -> mujoco.MjSpec:
|
||
"""Apply mass and center-of-mass position to a body."""
|
||
# TODO(b/0): assert mass and ipos shapes
|
||
body = _infer_inertial(spec, body_name)
|
||
mass_original = body.mass
|
||
if mass is not None:
|
||
body.mass = mass
|
||
if rot_inertia_scale:
|
||
scale = mass / mass_original
|
||
body.inertia *= scale
|
||
if ipos is not None:
|
||
body.ipos = ipos
|
||
return spec
|
||
|
||
|
||
def scale_body_inertia(
|
||
spec: mujoco.MjSpec,
|
||
body_name: str,
|
||
value: np.ndarray,
|
||
) -> mujoco.MjSpec:
|
||
# TODO(b/0): assert scalar
|
||
body = _infer_inertial(spec, body_name)
|
||
body.inertia *= value
|
||
return spec
|
||
|
||
|
||
def pi_from_theta(theta: np.ndarray) -> np.ndarray:
|
||
"""Convert base parameters θ to inertial parameters π."""
|
||
alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3 = theta
|
||
exp_alpha = np.exp(alpha)
|
||
exp_d1 = np.exp(d1)
|
||
exp_d2 = np.exp(d2)
|
||
exp_d3 = np.exp(d3)
|
||
U = np.zeros((4, 4))
|
||
U[0, 0] = exp_d1
|
||
U[0, 1] = s12
|
||
U[0, 2] = s13
|
||
U[0, 3] = t1
|
||
U[1, 1] = exp_d2
|
||
U[1, 2] = s23
|
||
U[1, 3] = t2
|
||
U[2, 2] = exp_d3
|
||
U[2, 3] = t3
|
||
U[3, 3] = 1
|
||
U *= exp_alpha
|
||
|
||
J = U @ U.T
|
||
|
||
sigma = J[:3, :3]
|
||
I_bar = np.trace(sigma) * np.eye(3) - sigma
|
||
h = J[:3, 3]
|
||
m = J[3, 3]
|
||
|
||
return np.concatenate(([m], h, I_bar.flatten()))
|
||
|
||
|
||
def pseudoinertia_from_pi(pi: np.ndarray) -> np.ndarray:
|
||
"""Converts inertial parameters π to a 4x4 pseudoinertia matrix J.
|
||
|
||
Args:
|
||
pi: A 10-D array [m, hx, hy, hz, Ixx, Iyy, Izz, Ixy, Iyz, Ixz].
|
||
|
||
Returns:
|
||
A 4x4 pseudoinertia matrix J.
|
||
"""
|
||
m = pi[0]
|
||
h = pi[1:4]
|
||
I_bar = pi[4:].reshape((3, 3))
|
||
|
||
Sigma = 0.5 * np.trace(I_bar) * np.eye(3) - I_bar
|
||
|
||
J = np.zeros((4, 4))
|
||
J[:3, :3] = Sigma
|
||
J[:3, 3] = h
|
||
J[3, :3] = h
|
||
J[3, 3] = m
|
||
|
||
return J
|
||
|
||
|
||
def cholesky_decompose_upper(J: np.ndarray) -> np.ndarray:
|
||
"""Perform an upper-triangular Cholesky decomposition of J.
|
||
|
||
The returned matrix U is such that J = U @ U.T.
|
||
|
||
Args:
|
||
J: A 4x4 positive-definite matrix.
|
||
|
||
Returns:
|
||
An upper-triangular matrix U.
|
||
"""
|
||
n = J.shape[0]
|
||
indices = np.arange(n - 1, -1, -1)
|
||
J_reversed = J[indices][:, indices]
|
||
L_prime = np.linalg.cholesky(J_reversed)
|
||
return L_prime[indices][:, indices]
|
||
|
||
|
||
def theta_from_pseudoinertia(J: np.ndarray) -> np.ndarray:
|
||
"""Extract the 10-D vector of base parameters θ from the pseudoinertia J.
|
||
|
||
Args:
|
||
J: A 4x4 pseudoinertia.
|
||
|
||
Returns:
|
||
A 10-D array θ = [alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3] where:
|
||
alpha: Scale parameter (log of U[3,3])
|
||
[d1, d2, d3]: Log of diagonal elements
|
||
[s12, s23, s13]: Shear parameters from upper triangle
|
||
[t1, t2, t3]: Translation parameters from last column
|
||
"""
|
||
# U: A 4x4 upper-triangular matrix from Cholesky decomposition
|
||
U = cholesky_decompose_upper(J)
|
||
|
||
# Extract exp(α) from the bottom-right element of U.
|
||
exp_alpha = U[3, 3]
|
||
alpha = np.log(exp_alpha)
|
||
|
||
# Compute the d parameters from the diagonal entries (adjusted by alpha).
|
||
d1 = np.log(U[0, 0] / exp_alpha)
|
||
d2 = np.log(U[1, 1] / exp_alpha)
|
||
d3 = np.log(U[2, 2] / exp_alpha)
|
||
|
||
# Extract the shear parameters (off-diagonals in the upper triangle).
|
||
s12 = U[0, 1] / exp_alpha
|
||
s13 = U[0, 2] / exp_alpha
|
||
s23 = U[1, 2] / exp_alpha
|
||
|
||
# Extract the translation parameters (last column, except the bottom element).
|
||
t1 = U[0, 3] / exp_alpha
|
||
t2 = U[1, 3] / exp_alpha
|
||
t3 = U[2, 3] / exp_alpha
|
||
|
||
return np.array([alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3])
|
||
|
||
|
||
def skew(v: np.ndarray) -> np.ndarray:
|
||
"""Skew-symmetric matrix from a length-3 vector."""
|
||
return np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
|
||
|
||
|
||
def inertia_to_fullinertia(q: np.ndarray, inertia: np.ndarray) -> np.ndarray:
|
||
"""Convert diagonal inertia with quaternion to full inertia matrix."""
|
||
xmat = np.empty(9)
|
||
mujoco.mju_quat2Mat(xmat, q)
|
||
R = xmat.reshape(3, 3)
|
||
return R @ np.diag(inertia) @ R.T
|
||
|
||
|
||
def pi_from_body(spec: mujoco.MjSpec, body_name: str) -> np.ndarray:
|
||
"""Extracts the 10-D vector of inertial parameters π from a MuJoCo body.
|
||
|
||
Args:
|
||
spec: MuJoCo model specification object.
|
||
body_name: Name of the body to extract parameters from.
|
||
|
||
Returns:
|
||
A 10-D numpy array π = [m, hx, hy, hz, Ixx, Iyy, Izz, Ixy, Iyz, Ixz] where:
|
||
m: Mass of the body
|
||
[hx, hy, hz]: First moment of mass (m * com, where com is center of mass)
|
||
[Ixx, Iyy, Izz, Ixy, Iyz, Ixz]: Rotational inertia about the origin of the
|
||
body-fixed reference frame.
|
||
"""
|
||
body = _infer_inertial(spec, body_name)
|
||
mass = body.mass
|
||
ipos = body.ipos
|
||
inertia = body.inertia
|
||
iquat = body.iquat
|
||
|
||
fullinertia = inertia_to_fullinertia(iquat, inertia)
|
||
# Transform inertial from ipos origin to body origin.
|
||
I_bar = fullinertia - (mass * skew(ipos) @ skew(ipos))
|
||
|
||
return np.concatenate([[mass], mass * ipos, I_bar.flatten()])
|
||
|
||
|
||
def theta_inertia_from_body(spec: mujoco.MjSpec, body_name: str) -> np.ndarray:
|
||
"""Extract base parameters θ from a body's inertial properties."""
|
||
pi = pi_from_body(spec, body_name)
|
||
J = pseudoinertia_from_pi(pi)
|
||
return theta_from_pseudoinertia(J)
|
||
|
||
|
||
def apply_body_theta_inertia(
|
||
spec: mujoco.MjSpec,
|
||
body_name: str,
|
||
theta: np.ndarray,
|
||
) -> mujoco.MjSpec:
|
||
"""Apply base-parameter inertia θ to a body in the spec."""
|
||
if theta.size != 10:
|
||
raise ValueError(f"theta must be a 10-element array, got {theta.size}.")
|
||
pi = pi_from_theta(theta)
|
||
|
||
body = _infer_inertial(spec, body_name)
|
||
body.mass = pi[0]
|
||
body.ipos = pi[1:4] / pi[0]
|
||
|
||
# This tells the compiler to ignore the diagonal inertia and instead
|
||
# calculate it from the full inertia.
|
||
body.inertia[:] = 0.0
|
||
body.iquat[:] = np.nan
|
||
|
||
I_bar = pi[4:].reshape((3, 3))
|
||
skew_ipos = skew(body.ipos)
|
||
fullinertia = I_bar + (body.mass * skew_ipos @ skew_ipos)
|
||
|
||
# MuJoCo's ordering is: M(1,1), M(2,2), M(3,3), M(1,2), M(1,3), M(2,3) which
|
||
# corresponds to Ixx, Iyy, Izz, Ixy, Ixz
|
||
body.fullinertia[0] = fullinertia[0, 0] # Ixx
|
||
body.fullinertia[1] = fullinertia[1, 1] # Iyy
|
||
body.fullinertia[2] = fullinertia[2, 2] # Izz
|
||
body.fullinertia[3] = fullinertia[0, 1] # Ixy
|
||
body.fullinertia[4] = fullinertia[0, 2] # Ixz
|
||
body.fullinertia[5] = fullinertia[1, 2] # Iyz
|
||
|
||
return spec
|
||
|
||
|
||
def apply_body_inertia(spec: mujoco.MjSpec, name: str, param: Parameter):
|
||
"""Apply inertia parameters to a body based on the parameter type."""
|
||
if not hasattr(param, "inertia_type"):
|
||
raise ValueError(
|
||
f"Parameter {param.name} does not have inertia_type attribute."
|
||
)
|
||
|
||
if param.inertia_type == InertiaType.Mass:
|
||
apply_body_mass_ipos(
|
||
spec, name, mass=param.value, rot_inertia_scale=param.scale_rot_inertia
|
||
)
|
||
|
||
elif param.inertia_type == InertiaType.MassIpos:
|
||
apply_body_mass_ipos(
|
||
spec,
|
||
name,
|
||
mass=param.value[0],
|
||
ipos=param.value[1:4],
|
||
rot_inertia_scale=param.scale_rot_inertia,
|
||
)
|
||
|
||
elif param.inertia_type == InertiaType.Pseudo:
|
||
apply_body_theta_inertia(spec, name, param.value)
|
||
|
||
|
||
def body_inertia_param(
|
||
spec: mujoco.MjSpec,
|
||
model: mujoco.MjModel,
|
||
body_name: str,
|
||
inertia_type: InertiaType = InertiaType.MassIpos,
|
||
scale_rot_inertia: bool = False,
|
||
mass_bound_mult: np.ndarray | None = None,
|
||
ipos_bound_off: np.ndarray | None = None,
|
||
stretch_bound_mult: np.ndarray | None = None,
|
||
shear_bound_off: np.ndarray | None = None,
|
||
param_name: str | None = None,
|
||
modifier: ModifierFn | None = None,
|
||
) -> Parameter:
|
||
"""Creates Parameter objects for the inertia of a body in a simplified manner.
|
||
|
||
Args:
|
||
spec: MuJoCo model specification object.
|
||
model: The MuJoCo model.
|
||
body_name: Name of the body to create the parameter for.
|
||
inertia_type: The type of inertia parameterization to use.
|
||
scale_rot_inertia: Whether to scale the original inertia when mass changes,
|
||
ignored with pseudo inertia.
|
||
mass_bound_mult: Multiplicative bounds for the mass parameter.
|
||
ipos_bound_off: Additive bounds for the ipos parameter.
|
||
stretch_bound_mult: Multiplicative bounds for the stretch parameters in the
|
||
pseudo-inertia parameterization.
|
||
shear_bound_off: Additive bounds for the shear parameters in the
|
||
pseudo-inertia parameterization.
|
||
param_name: Optional name for the parameter. Defaults to
|
||
``"{body_name}_inertia"``.
|
||
modifier: Optional custom modifier callback. If None, the default
|
||
:func:`apply_body_inertia` modifier is registered on the Parameter.
|
||
|
||
Returns:
|
||
A Parameter configured for the body's inertia.
|
||
"""
|
||
|
||
if mass_bound_mult is None:
|
||
mass_bound_mult = np.array([0.1, 10.0])
|
||
if ipos_bound_off is None:
|
||
ipos_bound_off = np.array([-0.5, 0.5])
|
||
if stretch_bound_mult is None:
|
||
stretch_bound_mult = np.array([0.5, 2.0])
|
||
if shear_bound_off is None:
|
||
shear_bound_off = np.array([-0.5, 0.5])
|
||
|
||
body = model.body(body_name)
|
||
if param_name is None:
|
||
param_name = f"{body_name}_inertia"
|
||
|
||
if modifier is None:
|
||
|
||
def _default_modifier(spec, param):
|
||
return apply_body_inertia(spec, body_name, param)
|
||
|
||
modifier = _default_modifier
|
||
|
||
if inertia_type == InertiaType.Mass:
|
||
param = Parameter(
|
||
param_name,
|
||
body.mass,
|
||
body.mass * mass_bound_mult[0],
|
||
body.mass * mass_bound_mult[1],
|
||
modifier=modifier,
|
||
)
|
||
param.inertia_type = inertia_type
|
||
param.scale_rot_inertia = scale_rot_inertia
|
||
|
||
elif inertia_type == InertiaType.MassIpos:
|
||
massipos0 = np.concatenate((body.mass, body.ipos))
|
||
massipos_low = np.concatenate(
|
||
(body.mass * mass_bound_mult[0], body.ipos + ipos_bound_off[0])
|
||
)
|
||
massipos_high = np.concatenate(
|
||
(body.mass * mass_bound_mult[1], body.ipos + ipos_bound_off[1])
|
||
)
|
||
param = Parameter(
|
||
param_name, massipos0, massipos_low, massipos_high, modifier=modifier
|
||
)
|
||
param.inertia_type = inertia_type
|
||
param.scale_rot_inertia = scale_rot_inertia
|
||
|
||
elif inertia_type == InertiaType.Pseudo:
|
||
theta_i_0 = theta_inertia_from_body(spec, body_name)
|
||
|
||
# mass = exp(2*alpha)
|
||
alpha = theta_i_0[0]
|
||
mass = np.exp(2 * alpha)
|
||
mass_bounds = mass * mass_bound_mult
|
||
alpha_bounds = 0.5 * np.log(mass_bounds)
|
||
|
||
# d1, d2, d3, stretch = exp(2*d)
|
||
# stretches body along principal axes
|
||
d = theta_i_0[1 : 1 + 3]
|
||
stretch = np.exp(2 * d)
|
||
stretch_bounds = stretch[:, np.newaxis] * np.atleast_2d(stretch_bound_mult)
|
||
d_bounds = 0.5 * np.log(stretch_bounds)
|
||
|
||
# s12, s23, s13
|
||
# shear the body
|
||
s_bounds = theta_i_0[4 : 4 + 3, np.newaxis] + np.atleast_2d(shear_bound_off)
|
||
|
||
# t1, t2, t3
|
||
# center of mass
|
||
t_bounds = theta_i_0[7:10, np.newaxis] + np.atleast_2d(ipos_bound_off)
|
||
|
||
theta_bounds = np.vstack([
|
||
alpha_bounds,
|
||
d_bounds,
|
||
s_bounds,
|
||
t_bounds,
|
||
])
|
||
param = Parameter(
|
||
param_name,
|
||
theta_i_0,
|
||
theta_bounds[:, 0],
|
||
theta_bounds[:, 1],
|
||
modifier=modifier,
|
||
)
|
||
param.inertia_type = inertia_type
|
||
|
||
else:
|
||
raise ValueError(f"Unknown inertia_type: {inertia_type}")
|
||
|
||
return param
|