Merge pull request #3079 from aftersomemath:sysid-pr

PiperOrigin-RevId: 868229512
Change-Id: I790bc08fc8b0745583a2f92d9ee2c5a19ba558ea
This commit is contained in:
Copybara-Service
2026-02-10 11:04:12 -08:00
49 changed files with 11554 additions and 0 deletions
+543
View File
@@ -0,0 +1,543 @@
# 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