Files
Mujoco_WASM/python/mujoco/sysid/_src/model_modifier.py
T
2026-02-11 00:22:56 -05:00

599 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Args:
model: MuJoCo model.
actuator_name: Name of the 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.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: Proportional gain value.
"""
# 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.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: Derivative gain value.
"""
# 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.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: 2-element array ``[P_gain, D_gain]``.
"""
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.
Args:
spec: MuJoCo model specification.
body_name: Name of the body.
mass: Optional new mass value.
ipos: Optional new center-of-mass position.
rot_inertia_scale: If True, scale rotational inertia proportionally to
mass change.
"""
# 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 π.
Args:
theta: 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
Returns:
10-D array π = [m, hx, hy, hz, Ixx, Iyy, Izz, Ixy, Iyz, Ixz].
"""
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.
Args:
spec: MuJoCo model specification.
body_name: Name of the body.
theta: 10-element array [alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3].
"""
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.
Args:
spec: MuJoCo model specification.
name: Name of the body.
param: Parameter with an ``inertia_type`` attribute.
"""
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