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
+15
View File
@@ -0,0 +1,15 @@
# 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.
# ==============================================================================
+224
View File
@@ -0,0 +1,224 @@
# 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.
# ==============================================================================
"""Shared fixtures for mujoco.sysid tests."""
import mujoco
from mujoco.sysid._src import parameter
from mujoco.sysid._src import timeseries
from mujoco.sysid._src.model_modifier import _infer_inertial
import numpy as np
import pytest
# ---------------------------------------------------------------------------
# Inline model XML strings — no external file dependencies
# ---------------------------------------------------------------------------
BOX_XML = """\
<mujoco model="box">
<compiler angle="radian"/>
<option integrator="implicitfast" timestep="0.001" cone="elliptic">
<flag contact="enable"/>
</option>
<asset>
<texture type="2d" name="groundplane" builtin="checker" width="300" height="300"/>
<material name="groundplane" texture="groundplane"/>
</asset>
<worldbody>
<body name="floor">
<geom name="floor" type="box" size="100 100 0.01" pos="0 0 -0.01"/>
</body>
<body name="box" pos="0 0 0.102">
<freejoint name="box_free"/>
<inertial pos="0 0 0" mass="5" diaginertia="0.0333 0.0333 0.0333"/>
<geom name="box" type="box" size="0.1 0.1 0.1"/>
</body>
</worldbody>
<actuator>
<motor name="push_x" joint="box_free" gear="1 0 0 0 0 0"
ctrllimited="true" ctrlrange="-20 20"/>
</actuator>
<contact>
<pair name="box_floor" geom1="box" geom2="floor"
friction="1.6 0.005 0.0001" solref="0.01 1.0"/>
</contact>
</mujoco>
"""
ARM_XML = """\
<mujoco model="test_arm">
<compiler angle="radian" autolimits="true"/>
<option integrator="implicitfast">
<flag contact="disable"/>
</option>
<asset>
<texture name="tex1" type="2d" builtin="checker" width="64" height="64"/>
<material name="mat1" texture="tex1"/>
</asset>
<worldbody>
<body name="link1" pos="0 0 0.1">
<inertial pos="0 0 0.05" mass="1.0" diaginertia="0.01 0.01 0.005"/>
<joint name="joint1" type="hinge" axis="0 0 1" range="-3.14 3.14"/>
<geom type="capsule" fromto="0 0 0 0 0 0.1" size="0.04"/>
<body name="link2" pos="0 0 0.1">
<inertial pos="0 0 0.05" mass="0.8" diaginertia="0.008 0.008 0.004"/>
<joint name="joint2" type="hinge" axis="0 1 0" range="-3.14 3.14"/>
<geom type="capsule" fromto="0 0 0 0 0 0.1" size="0.035"/>
<body name="link3" pos="0 0 0.1">
<inertial pos="0 0 0.05" mass="0.6" diaginertia="0.006 0.006 0.003"/>
<joint name="joint3" type="hinge" axis="0 1 0" range="-3.14 3.14"/>
<geom type="capsule" fromto="0 0 0 0 0 0.1" size="0.03"/>
<body name="link4" pos="0 0 0.1">
<inertial pos="0 0 0.04" mass="0.4" diaginertia="0.004 0.004 0.002"/>
<joint name="joint4" type="hinge" axis="0 0 1" range="-3.14 3.14"/>
<geom type="capsule" fromto="0 0 0 0 0 0.08" size="0.025"/>
<body name="link5" pos="0 0 0.08">
<inertial pos="0 0 0.03" mass="0.2" diaginertia="0.002 0.002 0.001"/>
<joint name="joint5" type="hinge" axis="0 1 0" range="-3.14 3.14"/>
<geom type="capsule" fromto="0 0 0 0 0 0.06" size="0.02"/>
</body>
</body>
</body>
</body>
</body>
</worldbody>
<actuator>
<position name="actuator1" joint="joint1" kp="400" kv="40"/>
<position name="actuator2" joint="joint2" kp="400" kv="40"/>
<position name="actuator3" joint="joint3" kp="400" kv="40"/>
<position name="actuator4" joint="joint4" kp="400" kv="40"/>
<position name="actuator5" joint="joint5" kp="200" kv="20"/>
</actuator>
<sensor>
<jointpos name="joint1_pos" joint="joint1"/>
<jointpos name="joint2_pos" joint="joint2"/>
<jointpos name="joint3_pos" joint="joint3"/>
<jointpos name="joint4_pos" joint="joint4"/>
<jointpos name="joint5_pos" joint="joint5"/>
<jointvel name="joint1_vel" joint="joint1"/>
<jointvel name="joint2_vel" joint="joint2"/>
<jointvel name="joint3_vel" joint="joint3"/>
<jointvel name="joint4_vel" joint="joint4"/>
<jointvel name="joint5_vel" joint="joint5"/>
<jointactuatorfrc name="joint1_torque" joint="joint1"/>
<jointactuatorfrc name="joint2_torque" joint="joint2"/>
<jointactuatorfrc name="joint3_torque" joint="joint3"/>
<jointactuatorfrc name="joint4_torque" joint="joint4"/>
<jointactuatorfrc name="joint5_torque" joint="joint5"/>
</sensor>
</mujoco>
"""
OSCILLATOR_XML = """\
<mujoco>
<compiler autolimits="true"/>
<option>
<flag gravity="disable" contact="disable" limit="disable"/>
</option>
<worldbody>
<body name="mass">
<joint name="j1" axis="1 0 0" type="slide" range="0 0.5"
stiffness="10" damping="1"/>
<geom type="box" size=".1 .1 .1" mass="0.1"/>
</body>
</worldbody>
</mujoco>
"""
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def box_spec() -> mujoco.MjSpec:
return mujoco.MjSpec.from_string(BOX_XML)
@pytest.fixture
def box_model(box_spec) -> mujoco.MjModel:
return box_spec.compile()
@pytest.fixture
def arm_spec() -> mujoco.MjSpec:
"""Minimal 5-joint arm with sensors, actuators, textures/materials."""
return mujoco.MjSpec.from_string(ARM_XML)
@pytest.fixture
def arm_model(arm_spec) -> mujoco.MjSpec:
return arm_spec.compile()
@pytest.fixture
def oscillator_spec() -> mujoco.MjSpec:
"""Single-body oscillator with implicit (geom-based) inertia."""
return mujoco.MjSpec.from_string(OSCILLATOR_XML)
@pytest.fixture
def simple_timeseries() -> timeseries.TimeSeries:
"""A TimeSeries with 5 data points and 2 columns: y = [x^2, 2*x^2]."""
times = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
data = np.array([
[0.0, 0.0],
[1.0, 2.0],
[4.0, 8.0],
[9.0, 18.0],
[16.0, 32.0],
])
return timeseries.TimeSeries(times=times, data=data)
@pytest.fixture
def box_params(box_spec) -> parameter.ParameterDict:
"""ParameterDict for box model with modifier callbacks."""
del box_spec
pdict = parameter.ParameterDict()
pdict.add(
parameter.Parameter(
"box_mass",
[5],
min_value=[4.5],
max_value=[5.5],
modifier=lambda s, p: setattr(
_infer_inertial(s, "box"), "mass", p.value[0]
),
)
)
pdict.add(
parameter.Parameter(
"solref1",
[0.01],
min_value=[0.002],
max_value=[0.02],
modifier=lambda s, p: s.pair("box_floor").solref.__setitem__(
0, p.value[0]
),
)
)
pdict.add(
parameter.Parameter(
"friction2",
[0.005],
min_value=[0],
max_value=[0.01],
modifier=lambda s, p: s.pair("box_floor").friction.__setitem__(
1, p.value[0]
),
)
)
return pdict
@@ -0,0 +1,249 @@
# 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.
# ==============================================================================
"""End-to-end integration test using the box model."""
import pathlib
import tempfile
import mujoco
import mujoco.rollout as mj_rollout
from mujoco.sysid._src import signal_modifier
from mujoco.sysid._src import timeseries
from mujoco.sysid._src.io import save_results
from mujoco.sysid._src.model_modifier import _infer_inertial
from mujoco.sysid._src.optimize import optimize
from mujoco.sysid._src.parameter import Parameter
from mujoco.sysid._src.parameter import ParameterDict
from mujoco.sysid._src.residual import build_residual_fn
from mujoco.sysid._src.trajectory import ModelSequences
from mujoco.sysid._src.trajectory import create_initial_state
from mujoco.sysid.tests.conftest import BOX_XML
import numpy as np
def _generate_box_data(
spec: mujoco.MjSpec,
duration: float = 1.0,
) -> tuple[timeseries.TimeSeries, timeseries.TimeSeries, np.ndarray]:
"""Generate synthetic box-pushing data via rollout."""
model = spec.compile()
data = mujoco.MjData(model)
n_steps = int(duration / model.opt.timestep)
t = np.arange(n_steps) * model.opt.timestep
force = (np.sin(t) * 3.0).reshape(-1, 1)
control_ts = timeseries.TimeSeries(t, force)
initial_state = create_initial_state(model, data.qpos, data.qvel, data.act)
control_applied = force[:-1]
state, _ = mj_rollout.rollout(model, data, initial_state, control_applied)
state = np.squeeze(state, axis=0)
sensor_ids = [1, 8]
signal_mapping = {
"pos_x": (timeseries.SignalType.MjStateQPos, np.array([0])),
"vel_x": (timeseries.SignalType.MjStateQVel, np.array([1])),
}
state_times = state[:, 0]
sensordata = timeseries.TimeSeries(
state_times,
state[:, sensor_ids],
signal_mapping,
)
return control_ts, sensordata, initial_state
def _build_box_params() -> ParameterDict:
"""Build parameter dict with modifier callbacks matching box config."""
pdict = ParameterDict()
pdict.add(
Parameter(
"box_mass",
[5],
min_value=[4.5],
max_value=[5.5],
modifier=lambda s, p: setattr(
_infer_inertial(s, "box"), "mass", p.value[0]
),
)
)
pdict.add(
Parameter(
"solref1",
[0.01],
min_value=[0.002],
max_value=[0.02],
modifier=lambda s, p: s.pair("box_floor").solref.__setitem__(
0, p.value[0]
),
)
)
pdict.add(
Parameter(
"solref2",
[1.0],
min_value=[0.3],
max_value=[1.7],
frozen=True,
modifier=lambda s, p: s.pair("box_floor").solref.__setitem__(
1, p.value[0]
),
)
)
pdict.add(
Parameter(
"friction1",
[1.6],
min_value=[0],
max_value=[3.0],
frozen=True,
modifier=lambda s, p: s.pair("box_floor").friction.__setitem__(
0, p.value[0]
),
)
)
pdict.add(
Parameter(
"friction2",
[0.005],
min_value=[0],
max_value=[0.01],
modifier=lambda s, p: s.pair("box_floor").friction.__setitem__(
1, p.value[0]
),
)
)
pdict.add(
Parameter(
"friction3",
[0.0001],
min_value=[0],
max_value=[0.001],
frozen=True,
modifier=lambda s, p: s.pair("box_floor").friction.__setitem__(
2, p.value[0]
),
)
)
return pdict
def test_box_end_to_end():
"""Full 5-stage pipeline: generate data, build residual, optimize 3 iters, save."""
spec = mujoco.MjSpec.from_string(BOX_XML)
# 1. Generate synthetic ground-truth data.
control, sensordata, initial_state = _generate_box_data(spec, duration=1.0)
# 2. Build config with known parameters.
params = _build_box_params()
# 3. Create ModelSequences.
models_sequences = [
ModelSequences(
"box",
spec,
"push",
initial_state,
control,
sensordata,
allow_missing_sensors=True,
)
]
# 4. Define modify_residual (box uses state-based residual).
def modify_residual(
_params,
_sensordata_predicted,
sensordata_measured,
model,
_return_pred_all,
state=None,
**_kwargs,
):
assert state is not None
sensor_ids = [1, 8]
statedata_predicted = timeseries.TimeSeries(
state[:, 0],
state[..., sensor_ids],
{
"pos_x": (timeseries.SignalType.MjStateQPos, np.array([0])),
"vel_x": (timeseries.SignalType.MjStateQVel, np.array([1])),
},
)
sensordata_measured = signal_modifier.apply_delayed_ts_window(
sensordata_measured, statedata_predicted, 0.0, 0.0
)
statedata_predicted = statedata_predicted.resample(
sensordata_measured.times
)
res = signal_modifier.weighted_diff(
predicted_data=statedata_predicted.data,
measured_data=sensordata_measured.data,
model=model,
)
res = signal_modifier.normalize_residual(res, sensordata_measured.data)
return res, statedata_predicted, sensordata_measured
residual_fn = build_residual_fn(
models_sequences=models_sequences,
modify_residual=modify_residual,
)
# 5. Perturb params and optimize (3 iters to verify).
rng = np.random.default_rng(42)
params.randomize(rng=rng)
# Compute initial cost.
initial_residuals, _, _ = residual_fn(params.as_vector(), params)
initial_cost = sum(np.sum(r**2) for r in initial_residuals)
opt_params, opt_result = optimize(
initial_params=params,
residual_fn=residual_fn,
optimizer="mujoco",
max_iters=3,
verbose=False,
)
# 6. Assert basic properties.
assert opt_result.x.shape == params.as_vector().shape
# Compute final cost.
final_residuals, _, _ = residual_fn(opt_result.x, opt_params)
final_cost = sum(np.sum(r**2) for r in final_residuals)
assert (
final_cost <= initial_cost
), f"Cost should decrease: {final_cost} > {initial_cost}"
# 7. Save results to a temp dir.
with tempfile.TemporaryDirectory() as tmpdir:
save_results(
experiment_results_folder=tmpdir,
models_sequences=models_sequences,
initial_params=params,
opt_params=opt_params,
opt_result=opt_result,
residual_fn=residual_fn,
)
result_dir = pathlib.Path(tmpdir)
assert (result_dir / "params_x_0.yaml").exists()
assert (result_dir / "params_x_hat.yaml").exists()
assert (result_dir / "results.pkl").exists()
assert (result_dir / "confidence.pkl").exists()
assert (result_dir / "box.xml").exists()
@@ -0,0 +1,131 @@
# 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.
# ==============================================================================
"""Tests for the model_modifier module."""
import mujoco
from mujoco.sysid._src import model_modifier
import numpy as np
def test_apply_pgain(arm_spec):
"""Setting a P gain correctly configures the underlying actuator parameters."""
actuator_name = "actuator5"
pgain_value = 74
modified_spec = model_modifier.apply_pgain(
arm_spec, actuator_name, pgain_value
)
model = modified_spec.compile()
assert model.actuator(actuator_name).gainprm[0] == pgain_value
assert model.actuator(actuator_name).biasprm[1] == -pgain_value
def test_apply_dgain(arm_spec):
"""Setting a D gain correctly configures the underlying actuator parameters."""
actuator_name = "actuator5"
dgain_value = 1.2
modified_spec = model_modifier.apply_dgain(
arm_spec, actuator_name, dgain_value
)
model = modified_spec.compile()
assert model.actuator(actuator_name).biasprm[2] == -dgain_value
def test_apply_pdgain(arm_spec):
"""Setting P and D gains together from a single array configures both correctly."""
actuator_name = "actuator5"
pdgain_value = np.array([74, 1.2])
modified_spec = model_modifier.apply_pdgain(
arm_spec, actuator_name, pdgain_value
)
model = modified_spec.compile()
assert model.actuator(actuator_name).gainprm[0] == pdgain_value[0]
assert model.actuator(actuator_name).biasprm[1] == -pdgain_value[0]
assert model.actuator(actuator_name).biasprm[2] == -pdgain_value[1]
def test_apply_body_mass_explicit(arm_spec):
"""Bodies with inertia defined in XML: changing mass proportionally scales inertia."""
body_name = "link1"
model = arm_spec.compile()
original_mass = model.body(body_name).mass[0]
original_inertia = model.body(body_name).inertia
del model
scale = 3.3
new_mass = scale * original_mass
modified_spec = model_modifier.apply_body_mass_ipos(
arm_spec, body_name, mass=new_mass, rot_inertia_scale=True
)
model = modified_spec.compile()
assert model.body(body_name).mass == new_mass
np.testing.assert_allclose(
model.body(body_name).inertia, original_inertia * scale
)
def test_apply_body_mass_implicit(oscillator_spec):
"""Bodies with inertia inferred from geoms: changing mass proportionally scales inertia."""
body_name = "mass"
model = oscillator_spec.compile()
original_mass = model.body(body_name).mass[0]
original_inertia = model.body(body_name).inertia
del model
scale = 0.077
new_mass = scale * original_mass
modified_spec = model_modifier.apply_body_mass_ipos(
oscillator_spec, body_name, mass=new_mass, rot_inertia_scale=True
)
model = modified_spec.compile()
assert model.body(body_name).mass == new_mass
np.testing.assert_allclose(
model.body(body_name).inertia, original_inertia * scale
)
def test_remove_visuals(arm_spec):
"""Stripping visuals removes all textures and materials for faster compilation."""
cleaned_spec = model_modifier.remove_visuals(arm_spec)
assert not cleaned_spec.textures
assert not cleaned_spec.materials
def test_apply_param_modifiers(box_spec, box_params):
"""The full modifier pipeline applies parameter callbacks and produces an updated model."""
spec = box_spec.copy()
original_model = spec.compile()
original_mass = original_model.body("box").mass[0]
# Change box_mass parameter.
box_params["box_mass"].update_from_vector(np.array([5.3]))
modified_model = model_modifier.apply_param_modifiers(box_params, spec)
assert modified_model.body("box").mass[0] != original_mass
np.testing.assert_allclose(modified_model.body("box").mass[0], 5.3, atol=1e-6)
# Also verify apply_param_modifiers_spec returns MjSpec.
spec2 = box_spec.copy()
result = model_modifier.apply_param_modifiers_spec(box_params, spec2)
assert isinstance(result, mujoco.MjSpec)
+148
View File
@@ -0,0 +1,148 @@
# 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.
# ==============================================================================
"""Tests for the Parameter and ParameterDict classes."""
from mujoco.sysid._src import parameter
import numpy as np
def test_scalar_parameter():
"""A single-valued parameter round-trips through vector conversion, sampling, and reset."""
param = parameter.Parameter("test", 1.0, 0.5, 2.0)
assert param.name == "test"
assert param.size == 1
assert param.shape == (1,)
assert param.nominal == 1.0
assert param.value == 1.0
assert param.min_value == 0.5
assert param.max_value == 2.0
np.testing.assert_array_equal(param.as_vector(), [1.0])
param.update_from_vector(np.array([1.5]))
np.testing.assert_array_equal(param.value, [1.5])
np.testing.assert_array_equal(param.as_vector(), [1.5])
lower, upper = param.get_bounds()
np.testing.assert_array_equal(lower, [0.5])
np.testing.assert_array_equal(upper, [2.0])
param.reset()
np.testing.assert_array_equal(param.value, [1.0])
rng = np.random.default_rng(42)
sample = param.sample(rng)
assert 0.5 <= sample[0] <= 2.0
def test_vector_parameter():
"""A multi-valued parameter preserves element-wise bounds and resets correctly."""
param = parameter.Parameter("test_vector", [1.0, 2.0], [0.5, 1.0], [2.0, 3.0])
assert param.name == "test_vector"
assert param.size == 2
assert param.shape == (2,)
np.testing.assert_array_equal(param.nominal, [1.0, 2.0])
np.testing.assert_array_equal(param.value, [1.0, 2.0])
np.testing.assert_array_equal(param.min_value, [0.5, 1.0])
np.testing.assert_array_equal(param.max_value, [2.0, 3.0])
np.testing.assert_array_equal(param.as_vector(), [1.0, 2.0])
param.update_from_vector(np.array([1.5, 2.5]))
np.testing.assert_array_equal(param.value, [1.5, 2.5])
lower, upper = param.get_bounds()
np.testing.assert_array_equal(lower, [0.5, 1.0])
np.testing.assert_array_equal(upper, [2.0, 3.0])
param.reset()
np.testing.assert_array_equal(param.value, [1.0, 2.0])
def test_parameter_dict():
"""A dict of mixed scalar/vector params flattens to one vector and reconstructs."""
param1 = parameter.Parameter("param1", 1.0, 0.5, 2.0)
param2 = parameter.Parameter("param2", [2.0, 3.0], [1.0, 2.0], [3.0, 4.0])
params = parameter.ParameterDict({"param1": param1, "param2": param2})
assert params.size == 3 # 1 + 2
assert len(params) == 2
assert params["param1"] is param1
assert params["param2"] is param2
np.testing.assert_array_equal(params.as_vector(), [1.0, 2.0, 3.0])
params.update_from_vector(np.array([1.5, 2.5, 3.5]))
np.testing.assert_array_equal(params["param1"].value, [1.5])
np.testing.assert_array_equal(params["param2"].value, [2.5, 3.5])
lower, upper = params.get_bounds()
np.testing.assert_array_equal(lower, [0.5, 1.0, 2.0])
np.testing.assert_array_equal(upper, [2.0, 3.0, 4.0])
params.reset()
np.testing.assert_array_equal(params["param1"].value, [1.0])
np.testing.assert_array_equal(params["param2"].value, [2.0, 3.0])
rng = np.random.default_rng(42)
sample = params.sample(rng=rng)
assert len(sample) == 3
def test_save_and_load_round_trip(tmp_path):
"""Saving to YAML and loading back recovers modified values, nominals, and bounds."""
param1 = parameter.Parameter("p1", 1.0, 0.0, 2.0)
param2 = parameter.Parameter("p2", [3.0, 4.0], [1.0, 2.0], [5.0, 6.0])
params = parameter.ParameterDict({"p1": param1, "p2": param2})
params.update_from_vector(np.array([0.7, 3.5, 4.5]))
path = tmp_path / "params.yaml"
params.save_to_disk(path)
loaded = parameter.ParameterDict.load_from_disk(path)
np.testing.assert_array_equal(loaded.as_vector(), [0.7, 3.5, 4.5])
np.testing.assert_array_equal(loaded["p1"].nominal, [1.0])
np.testing.assert_array_equal(loaded["p2"].min_value, [1.0, 2.0])
def test_randomize_stays_in_bounds():
"""Randomized parameter values always stay within their declared bounds."""
param1 = parameter.Parameter("a", 5.0, 2.0, 8.0)
param2 = parameter.Parameter("b", [1.0, 2.0], [0.0, 0.0], [3.0, 3.0])
params = parameter.ParameterDict({"a": param1, "b": param2})
rng = np.random.default_rng(0)
for _ in range(10):
params.randomize(rng=rng)
lower, upper = params.get_bounds()
vec = params.as_vector()
assert np.all(vec >= lower)
assert np.all(vec <= upper)
def test_frozen_param_excluded():
"""Freezing a parameter hides it from the optimizer: excluded from vector ops."""
p1 = parameter.Parameter("free", 1.0, 0.0, 2.0)
p2 = parameter.Parameter("frozen", 5.0, 3.0, 7.0, frozen=True)
params = parameter.ParameterDict({"free": p1, "frozen": p2})
assert params.size == 1
np.testing.assert_array_equal(params.as_vector(), [1.0])
params.update_from_vector(np.array([1.5]))
np.testing.assert_array_equal(params["free"].value, [1.5])
# Frozen param unchanged.
np.testing.assert_array_equal(params["frozen"].value, [5.0])
+391
View File
@@ -0,0 +1,391 @@
# 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.
# ==============================================================================
"""Tests for signal_modifier and SignalTransform."""
from mujoco.sysid._src import parameter
from mujoco.sysid._src import signal_modifier
from mujoco.sysid._src import timeseries
from mujoco.sysid._src.parameter import Parameter
from mujoco.sysid._src.parameter import ParameterDict
from mujoco.sysid._src.signal_transform import SignalTransform
import numpy as np
import pytest
# ===========================================================================
# Helpers
# ===========================================================================
def _make_pdict(*params: Parameter) -> ParameterDict:
pdict = ParameterDict()
for p in params:
pdict.add(p)
return pdict
def _make_arm_sensor_ts(arm_model):
"""Create a synthetic TimeSeries with signal_mapping matching arm sensors."""
n_steps = 10
n_sensors = arm_model.nsensordata
times = np.linspace(0, 1, n_steps)
data = np.random.default_rng(42).standard_normal((n_steps, n_sensors))
mapping = {}
for i in range(arm_model.nsensor):
name = arm_model.sensor(i).name
adr = arm_model.sensor_adr[i]
dim = arm_model.sensor_dim[i]
mapping[name] = (timeseries.SignalType.MjSensor, np.arange(adr, adr + dim))
return timeseries.TimeSeries(times, data, signal_mapping=mapping)
def _make_resample_ts(n_steps, n_cols, seed=0):
"""Deterministic TimeSeries for resample tests."""
rng = np.random.default_rng(seed)
times = np.linspace(0, 1, n_steps)
data = rng.standard_normal((n_steps, n_cols))
mapping = {
f"s{i}": (timeseries.SignalType.MjSensor, np.array([i]))
for i in range(n_cols)
}
return timeseries.TimeSeries(times, data, signal_mapping=mapping)
def _make_transform_sensor_ts(n_steps=50, n_sensors=15, seed=0):
"""Deterministic TimeSeries with named MjSensor columns for transforms."""
rng = np.random.default_rng(seed)
times = np.linspace(0, 1, n_steps)
data = rng.standard_normal((n_steps, n_sensors))
mapping = {
f"joint{i + 1}_pos": (timeseries.SignalType.MjSensor, np.array([i]))
for i in range(5)
}
mapping.update({
f"joint{i - 4}_vel": (timeseries.SignalType.MjSensor, np.array([i]))
for i in range(5, 10)
})
mapping.update({
f"joint{i - 9}_torque": (timeseries.SignalType.MjSensor, np.array([i]))
for i in range(10, 15)
})
return timeseries.TimeSeries(times, data, signal_mapping=mapping)
def _run_both(ts, times, default_delay, sensor_delays, predicted_data):
"""Run grouped and column-wise implementations, return both."""
delays = signal_modifier._build_per_column_delays(
ts, default_delay, sensor_delays, predicted_data
)
reference = signal_modifier._apply_resample_and_delay_columnwise(
ts, times, delays
)
result = signal_modifier.apply_resample_and_delay(
ts,
times,
default_delay,
sensor_delays=sensor_delays,
predicted_data=predicted_data,
)
return result.data, reference
def _run_gains_biases_both(transform, ts, target_label, params):
"""Run new and reference implementations, return both results."""
new_result = transform._apply_gains_biases(ts, target_label, params)
ref_result = transform._apply_gains_biases_reference(ts, target_label, params)
return new_result, ref_result
# ===========================================================================
# signal_modifier: get_sensor_indices
# ===========================================================================
def test_get_sensor_indices(arm_model):
"""Sensor name lookup returns the right data column indices for one or many sensors."""
indices = signal_modifier.get_sensor_indices(arm_model, "joint1_pos")
assert isinstance(indices, list)
assert len(indices) == 1
indices = signal_modifier.get_sensor_indices(
arm_model, ["joint1_pos", "joint2_pos"]
)
assert len(indices) == 2
# ===========================================================================
# signal_modifier: apply_gain / apply_bias
# ===========================================================================
def test_apply_gain(arm_model):
"""Gain scaling affects only the named sensor's columns, leaving others untouched."""
ts = _make_arm_sensor_ts(arm_model)
gain = parameter.Parameter("gain", 2.0, 0.5, 3.0)
result = signal_modifier.apply_gain(ts, "joint1_torque", gain)
idx = ts.get_indices("joint1_torque")[1]
np.testing.assert_allclose(result.data[:, idx], ts.data[:, idx] * 2.0)
other = [i for i in range(ts.data.shape[1]) if i not in idx]
np.testing.assert_array_equal(result.data[:, other], ts.data[:, other])
def test_apply_bias(arm_model):
"""Bias offset affects only the named sensor's columns, leaving others untouched."""
ts = _make_arm_sensor_ts(arm_model)
bias = parameter.Parameter("bias", 0.5, -1.0, 1.0)
result = signal_modifier.apply_bias(ts, "joint1_pos", bias)
idx = ts.get_indices("joint1_pos")[1]
np.testing.assert_allclose(result.data[:, idx], ts.data[:, idx] + 0.5)
other = [i for i in range(ts.data.shape[1]) if i not in idx]
np.testing.assert_array_equal(result.data[:, other], ts.data[:, other])
# ===========================================================================
# signal_modifier: apply_delayed_ts_window
# ===========================================================================
def test_apply_delayed_ts_window(arm_model):
"""Time-windowing crops timestamps to the overlapping region between two series."""
ts = _make_arm_sensor_ts(arm_model)
ts_delayed = _make_arm_sensor_ts(arm_model)
result = signal_modifier.apply_delayed_ts_window(
ts, ts_delayed, min_delay=0.0, max_delay=0.0
)
assert result.times[0] >= ts_delayed.times[0]
assert result.times[-1] <= ts_delayed.times[-1]
# ===========================================================================
# signal_modifier: weighted_diff / normalize_residual
# ===========================================================================
def test_weighted_diff_basic():
"""Without weights, the residual is simply measured minus predicted."""
predicted = np.array([[1.0, 2.0], [3.0, 4.0]])
measured = np.array([[1.1, 2.2], [3.3, 4.4]])
result = signal_modifier.weighted_diff(predicted, measured)
np.testing.assert_allclose(result, measured - predicted)
def test_weighted_diff_with_weights(arm_model):
"""Sensor weights let you emphasize or de-emphasize specific channels in the residual."""
n = arm_model.nsensordata
predicted = np.ones((5, n))
measured = np.ones((5, n)) * 2.0
weights = {"joint1_pos": 0.5}
result = signal_modifier.weighted_diff(
predicted, measured, arm_model, weights
)
idx = signal_modifier.get_sensor_indices(arm_model, "joint1_pos")
np.testing.assert_allclose(result[:, idx], 0.5)
other = [i for i in range(n) if i not in idx]
np.testing.assert_allclose(result[:, other], 1.0)
def test_normalize_residual():
"""Normalization makes residuals comparable across sensors with different scales."""
residual = np.array([[2.0, 4.0], [6.0, 8.0]])
measured = np.array([[1.0, 2.0], [3.0, 4.0]])
result = signal_modifier.normalize_residual(residual, measured)
norm = np.linalg.norm(measured, axis=0) / np.sqrt(2)
np.testing.assert_allclose(result, residual / norm)
# ===========================================================================
# signal_modifier: resample_and_delay grouped vs columnwise equivalence
# ===========================================================================
def test_resample_delay_mixed_delays():
"""Optimized grouped resampling gives identical results to naive per-column resampling."""
ts = _make_resample_ts(200, 8, seed=42)
out_times = np.linspace(0.05, 0.95, 150)
sensor_delays = {
"s0": 0.01,
"s1": 0.01,
"s2": 0.01,
"s3": 0.03,
"s4": 0.03,
}
result, reference = _run_both(ts, out_times, 0.0, sensor_delays, True)
np.testing.assert_array_equal(result, reference)
# ===========================================================================
# SignalTransform: pattern matching
# ===========================================================================
class TestPatternMatching:
"""Tests for signal transform pattern matching."""
def test_basic_glob(self):
"""Wildcard patterns select the right sensors (e.g.
'*_pos' matches positions only).
"""
transform = SignalTransform()
delay_param = Parameter("delay", [0.01], [0.0], [0.05])
transform.delay("*_pos", delay_param)
pdict = _make_pdict(delay_param)
resolved = transform._resolve_delays(
["joint1_pos", "joint2_pos", "joint1_vel"], pdict
)
assert "joint1_pos" in resolved
assert "joint2_pos" in resolved
assert "joint1_vel" not in resolved
assert resolved["joint1_pos"] == pytest.approx(0.01)
def test_last_match_wins(self):
"""When patterns overlap, the last one registered takes priority."""
transform = SignalTransform()
general_delay = Parameter("delay_general", [0.01], [0.0], [0.05])
specific_delay = Parameter("delay_specific", [0.05], [0.0], [0.1])
transform.delay("*_torque", general_delay)
transform.delay("joint5_torque", specific_delay)
pdict = _make_pdict(general_delay, specific_delay)
resolved = transform._resolve_delays(
["joint1_torque", "joint5_torque"], pdict
)
assert resolved["joint1_torque"] == pytest.approx(0.01)
assert resolved["joint5_torque"] == pytest.approx(0.05)
def test_no_match(self):
"""Patterns that don't match any sensor names produce no delay entries."""
transform = SignalTransform()
delay_param = Parameter("delay", [0.01], [0.0], [0.05])
transform.delay("*_pos", delay_param)
pdict = _make_pdict(delay_param)
resolved = transform._resolve_delays(["joint1_vel", "joint2_vel"], pdict)
assert not resolved
# ===========================================================================
# SignalTransform: delay bounds
# ===========================================================================
class TestDelayBounds:
"""Tests for delay bound computation."""
def test_single_param(self):
"""The min/max delay window is derived from a parameter's declared bounds."""
transform = SignalTransform()
delay_param = Parameter("delay", [0.01], [-0.02], [0.05])
transform.delay("*_pos", delay_param)
min_d, max_d = transform._compute_delay_bounds()
assert min_d == pytest.approx(-0.02)
assert max_d == pytest.approx(0.05)
def test_dedup_by_name(self):
"""Reusing one delay param across patterns doesn't double-count its bounds."""
transform = SignalTransform()
delay_param = Parameter("delay", [0.01], [-0.01], [0.05])
transform.delay("*_pos", delay_param)
transform.delay("*_vel", delay_param)
min_d, max_d = transform._compute_delay_bounds()
assert min_d == pytest.approx(-0.01)
assert max_d == pytest.approx(0.05)
# ===========================================================================
# SignalTransform: edge cases
# ===========================================================================
class TestEdgeCases:
"""Tests for edge cases in signal transforms."""
def test_enable_sensors_stores_copy(self):
"""The sensor list is defensively copied so callers can't mutate it after the fact."""
transform = SignalTransform()
sensors = ["a", "b"]
transform.enable_sensors(sensors)
sensors.append("c")
assert transform._enabled_sensors == ["a", "b"]
def test_invalid_target(self):
"""Typos in the target argument ('predicted'/'measured'/'both') are caught early."""
transform = SignalTransform()
param = Parameter("gain", [1.0], [0.5], [2.0])
with pytest.raises(ValueError, match="target must be"):
transform.gain("*", param, target="invalid")
bias_param = Parameter("bias", [0.0], [-1.0], [1.0])
with pytest.raises(ValueError, match="target must be"):
transform.bias("*", bias_param, target="invalid")
# ===========================================================================
# SignalTransform: _apply_gains_biases equivalence
# ===========================================================================
class TestApplyGainsBiasesEquivalence:
"""Tests for gains/biases application equivalence."""
def test_gains_and_biases_mixed(self):
"""Applying gains and biases together produces the same result as the reference path."""
ts = _make_transform_sensor_ts()
gain = Parameter("torque_scale", [1.5], [0.5], [3.0])
bias = Parameter("torque_bias", [0.3], [-1.0], [1.0])
pdict = _make_pdict(gain, bias)
transform = SignalTransform()
transform.gain("*_torque", gain, target="both")
transform.bias("*_torque", bias, target="both")
new, ref = _run_gains_biases_both(transform, ts, "predicted", pdict)
np.testing.assert_array_equal(new.data, ref.data)
def test_target_filtering(self):
"""A gain meant for measured data doesn't accidentally affect the predicted side."""
ts = _make_transform_sensor_ts()
gain = Parameter("gain", [2.0], [0.5], [3.0])
pdict = _make_pdict(gain)
transform = SignalTransform()
transform.gain("*_torque", gain, target="measured")
new, ref = _run_gains_biases_both(transform, ts, "predicted", pdict)
np.testing.assert_array_equal(new.data, ref.data)
np.testing.assert_array_equal(new.data, ts.data)
def test_original_ts_not_mutated(self):
"""Signal transforms produce new data without mutating the input TimeSeries."""
ts = _make_transform_sensor_ts()
original_data = ts.data.copy()
gain = Parameter("gain", [2.0], [0.5], [3.0])
pdict = _make_pdict(gain)
transform = SignalTransform()
transform.gain("*_torque", gain, target="predicted")
transform._apply_gains_biases(ts, "predicted", pdict)
np.testing.assert_array_equal(ts.data, original_data)
@@ -0,0 +1,331 @@
# 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.
# ==============================================================================
"""Tests for the TimeSeries class and factory methods."""
import mujoco
from mujoco.sysid._src.timeseries import SignalType
from mujoco.sysid._src.timeseries import TimeSeries
import numpy as np
import pytest
# ---------------------------------------------------------------------------
# Local fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def scalar_ts():
"""y = x^2."""
times = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
data = np.array([0.0, 1.0, 4.0, 9.0, 16.0])
return TimeSeries(times=times, data=data)
@pytest.fixture
def multi_ts():
"""y = [x^2, 2*x^2]."""
times = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
data = np.array([
[0.0, 0.0],
[1.0, 2.0],
[4.0, 8.0],
[9.0, 18.0],
[16.0, 32.0],
])
return TimeSeries(times=times, data=data)
# ---------------------------------------------------------------------------
# Core TimeSeries tests
# ---------------------------------------------------------------------------
def test_basics(scalar_ts, multi_ts):
"""Basic properties: length, times array, and data array are all accessible."""
assert len(scalar_ts) == 5
assert len(multi_ts) == 5
np.testing.assert_array_equal(scalar_ts.times, [0, 1, 2, 3, 4])
np.testing.assert_array_equal(scalar_ts.data, [0, 1, 4, 9, 16])
@pytest.mark.parametrize(
"times, data, match",
[
(np.array([]), np.array([]), "Empty"),
(np.array([[0.0], [1.0]]), np.array([0.0, 1.0]), "1D"),
(np.array([0.0, 1.0]), np.array([0.0, 1.0, 2.0]), "Length"),
(
np.array([0.0, 2.0, 1.0]),
np.array([0.0, 1.0, 2.0]),
"strictly increasing",
),
],
)
def test_validation(times, data, match):
"""Bad inputs (empty, non-1D times, length mismatch, non-monotonic) are rejected."""
with pytest.raises(ValueError, match=match):
TimeSeries(times=times, data=data)
def test_zero_column_data():
"""Zero-column data is valid (state-based models with no sensors)."""
times = np.array([0.0, 1.0, 2.0])
data = np.empty((3, 0))
ts = TimeSeries(times=times, data=data)
assert len(ts) == 3
assert ts.data.shape == (3, 0)
def test_save_and_load(scalar_ts, multi_ts, tmp_path):
"""Saving to .npz and loading back recovers identical times and data."""
path = tmp_path / "test.npz"
scalar_ts.save_to_disk(path)
loaded = TimeSeries.load_from_disk(path)
np.testing.assert_array_equal(loaded.times, scalar_ts.times)
np.testing.assert_array_equal(loaded.data, scalar_ts.data)
path2 = tmp_path / "test_multi.npz"
multi_ts.save_to_disk(path2)
loaded2 = TimeSeries.load_from_disk(path2)
np.testing.assert_array_equal(loaded2.times, multi_ts.times)
np.testing.assert_array_equal(loaded2.data, multi_ts.data)
def test_save_and_load_with_signal_mapping(tmp_path):
"""Save/load also preserves the signal_mapping (sensor name -> column index map)."""
times = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
data = np.array([
[0.0, 0.0],
[1.0, 2.0],
[4.0, 8.0],
[9.0, 18.0],
[16.0, 32.0],
])
signal_mapping = {
"signal1": (SignalType.MjSensor, np.array([0])),
"signal2": (SignalType.MjSensor, np.array([1])),
}
ts = TimeSeries(
times=times, data=data, signal_mapping=signal_mapping
)
path = tmp_path / "test_signal_mapping.npz"
ts.save_to_disk(path)
loaded = TimeSeries.load_from_disk(path)
np.testing.assert_array_equal(loaded.times, times)
np.testing.assert_array_equal(loaded.data, data)
assert loaded.signal_mapping is not None
assert loaded.signal_mapping.keys() == signal_mapping.keys()
for key in signal_mapping:
val_type, val_indices = signal_mapping[key]
loaded_type, loaded_indices = loaded.signal_mapping[key]
assert loaded_type == val_type
np.testing.assert_array_equal(loaded_indices, val_indices)
@pytest.mark.parametrize(
"method, expected",
[
("linear", 6.5),
("cubic", 6.25),
("quadratic", 6.25),
("zero_order_hold", 4.0),
("zoh", 4.0),
],
)
def test_interpolate_scalar(scalar_ts, method, expected):
"""Each interpolation method (linear, cubic, ZOH, etc.) gives the expected midpoint value."""
result = scalar_ts.interpolate(2.5, method=method)
assert result[0] == pytest.approx(expected, abs=1e-5)
def test_interpolate_array(scalar_ts, multi_ts):
"""Interpolating at multiple times simultaneously works for scalar and multi-column data."""
t_values = np.array([0.5, 1.5, 2.5, 3.5])
expected = np.array([0.5, 2.5, 6.5, 12.5])
result = scalar_ts.interpolate(t_values, method="linear")
np.testing.assert_allclose(result, expected, rtol=1e-5)
expected_multi = np.array([
[0.5, 1.0],
[2.5, 5.0],
[6.5, 13.0],
[12.5, 25.0],
])
result_multi = multi_ts.interpolate(t_values, method="linear")
np.testing.assert_allclose(result_multi, expected_multi, rtol=1e-5)
def test_resample_with_new_times(scalar_ts):
"""Resampling onto a finer time grid via explicit new_times gives correct values."""
new_times = np.array([0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0])
expected = np.array([0.0, 0.5, 1.0, 2.5, 4.0, 6.5, 9.0, 12.5, 16.0])
resampled = scalar_ts.resample(new_times=new_times, method="linear")
np.testing.assert_array_equal(resampled.times, new_times)
np.testing.assert_allclose(resampled.data, expected, rtol=1e-5)
with pytest.raises(ValueError):
scalar_ts.resample(new_times=np.array([0.0, 2.0, 1.0]))
with pytest.raises(ValueError):
scalar_ts.resample(new_times=np.array([[0.0], [1.0]]))
def test_resample_with_target_dt(scalar_ts):
"""Resampling by specifying a target timestep generates the right uniform grid."""
resampled = scalar_ts.resample(target_dt=0.5, method="linear")
expected_times = np.array([0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0])
expected_data = np.array([0.0, 0.5, 1.0, 2.5, 4.0, 6.5, 9.0, 12.5, 16.0])
np.testing.assert_allclose(resampled.times, expected_times, rtol=1e-5)
np.testing.assert_allclose(resampled.data, expected_data, rtol=1e-5)
with pytest.raises(ValueError):
scalar_ts.resample(target_dt=-0.5)
with pytest.raises(ValueError):
scalar_ts.resample()
# ---------------------------------------------------------------------------
# TimeSeries factory method tests
# ---------------------------------------------------------------------------
def test_from_model_controls_auto_resolution():
"""Without explicit names, all model actuators are auto-discovered and mapped."""
xml = """
<mujoco>
<worldbody>
<body>
<joint name="j1"/>
<geom size="0.1" mass="1"/>
</body>
</worldbody>
<actuator>
<motor name="m1" joint="j1"/>
<motor name="m2" joint="j1"/>
</actuator>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
times = np.linspace(0, 1, 100)
data = np.random.randn(100, 2)
ts = TimeSeries.from_control_names(times, data, model)
assert ts.signal_mapping is not None
assert "m1_ctrl" in ts.signal_mapping
assert "m2_ctrl" in ts.signal_mapping
assert ts.signal_mapping["m1_ctrl"][0] == SignalType.MjCtrl
assert ts.signal_mapping["m2_ctrl"][0] == SignalType.MjCtrl
def test_from_model_controls_explicit_names():
"""Explicit actuator names are resolved; invalid or wrong-type names are rejected."""
xml = """
<mujoco>
<worldbody>
<body>
<joint name="j1"/>
<geom size="0.1" mass="1"/>
</body>
</worldbody>
<actuator>
<motor name="m1" joint="j1"/>
</actuator>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
times = np.linspace(0, 1, 10)
data = np.zeros((10, 1))
ts = TimeSeries.from_control_names(times, data, model, names=["m1"])
assert ts.signal_mapping is not None
assert "m1_ctrl" in ts.signal_mapping
with pytest.raises(ValueError, match="Could not resolve signal"):
TimeSeries.from_control_names(times, data, model, names=["invalid"])
with pytest.raises(ValueError, match="not allowed"):
TimeSeries.from_control_names(
times, data, model, names=[("m1", SignalType.MjSensor)]
)
def test_from_model_auto_resolution_sensors():
"""Without explicit names, all model sensors are auto-discovered with correct dimensions."""
xml = """
<mujoco>
<worldbody>
<body name="b1">
<geom size="0.1" mass="1"/>
<site name="s1"/>
</body>
</worldbody>
<sensor>
<accelerometer name="acc1" site="s1"/>
<gyro name="gyro1" site="s1"/>
</sensor>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
times = np.linspace(0, 1, 10)
data = np.zeros((10, 6))
ts = TimeSeries.from_names(times, data, model)
assert ts.signal_mapping is not None
assert "acc1" in ts.signal_mapping
assert "gyro1" in ts.signal_mapping
assert ts.signal_mapping["acc1"][0] == SignalType.MjSensor
assert ts.signal_mapping["gyro1"][0] == SignalType.MjSensor
def test_from_model_state_resolution():
"""State signals (qpos, qvel) can be mapped by passing (name, SignalType) tuples."""
xml = """
<mujoco>
<worldbody>
<body name="b1">
<joint name="j1" type="hinge"/>
<joint name="j2" type="slide"/>
<geom size="0.1" mass="1"/>
</body>
</worldbody>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
times = np.linspace(0, 1, 10)
data = np.zeros((10, 2))
names = [("j1", SignalType.MjStateQPos), ("j2", SignalType.MjStateQPos)]
ts = TimeSeries.from_names(times, data, model, names=names)
assert ts.signal_mapping is not None
assert "j1_qpos" in ts.signal_mapping
assert "j2_qpos" in ts.signal_mapping
def test_from_custom():
"""Custom signal definitions (name strings and dimension tuples) are mapped correctly."""
times = np.linspace(0, 1, 10)
data = np.zeros((10, 3))
signals = ["a", ("b", 2, SignalType.CustomObs)]
ts = TimeSeries.from_custom_map(times, data, signals)
assert ts.signal_mapping is not None
assert "a" in ts.signal_mapping
assert "b" in ts.signal_mapping
assert ts.signal_mapping["a"][1].size == 1
assert ts.signal_mapping["b"][1].size == 2
@@ -0,0 +1,159 @@
# 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.
# ==============================================================================
"""Tests for the SystemTrajectory class."""
from unittest import mock
import mujoco
from mujoco.sysid._src import timeseries
from mujoco.sysid._src.trajectory import create_initial_state
from mujoco.sysid._src.trajectory import SystemTrajectory
import numpy as np
import pytest
@pytest.fixture
def mock_model():
model = mock.Mock(spec=mujoco.MjModel)
model.nsensordata = 2
model.nu = 1
model.nq = 1
model.nv = 1
return model
@pytest.fixture
def sample_trajectory(mock_model):
"""Create a sample SystemTrajectory for testing."""
with mock.patch.object(
SystemTrajectory, "check_compatible", return_value=None
):
times = np.array([0.0, 1.0, 2.0])
control_mapping = {"ctrl1": (timeseries.SignalType.MjCtrl, np.array([0]))}
sensordata_mapping = {
"sensor1": (timeseries.SignalType.MjSensor, np.array([0])),
"sensor2": (timeseries.SignalType.MjSensor, np.array([1])),
}
state_mapping = {
"qpos1": (timeseries.SignalType.MjStateQPos, np.array([0]))
}
control = timeseries.TimeSeries(
times=times,
data=np.array([[1.0], [2.0], [3.0]]),
signal_mapping=control_mapping,
)
sensordata = timeseries.TimeSeries(
times=times,
data=np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]),
signal_mapping=sensordata_mapping,
)
state = timeseries.TimeSeries(
times=times,
data=np.array([[0.01], [0.02], [0.03]]),
signal_mapping=state_mapping,
)
traj = SystemTrajectory(
model=mock_model,
control=control,
sensordata=sensordata,
initial_state=np.array([0.0]),
state=state,
)
yield traj, control_mapping, sensordata_mapping, state_mapping
def test_save_and_load_with_signal_mapping(
sample_trajectory, mock_model, tmp_path
):
"""Saving and loading a trajectory preserves all signal mappings (control, sensor, state)."""
traj, control_mapping, sensordata_mapping, state_mapping = sample_trajectory
path = tmp_path / "test_traj.npz"
traj.save_to_disk(path)
with mock.patch.object(
SystemTrajectory, "check_compatible", return_value=None
):
loaded = SystemTrajectory.load_from_disk(path, mock_model)
ctrl_map = loaded.control.signal_mapping
assert ctrl_map is not None
assert ctrl_map.keys() == control_mapping.keys()
for key in control_mapping:
assert ctrl_map[key][0] == control_mapping[key][0]
np.testing.assert_array_equal(ctrl_map[key][1], control_mapping[key][1])
sensor_map = loaded.sensordata.signal_mapping
assert sensor_map is not None
assert sensor_map.keys() == sensordata_mapping.keys()
for key in sensordata_mapping:
assert sensor_map[key][0] == sensordata_mapping[key][0]
np.testing.assert_array_equal(
sensor_map[key][1], sensordata_mapping[key][1]
)
assert loaded.state is not None
state_map = loaded.state.signal_mapping
assert state_map is not None
assert state_map.keys() == state_mapping.keys()
for key in state_mapping:
assert state_map[key][0] == state_mapping[key][0]
np.testing.assert_array_equal(state_map[key][1], state_mapping[key][1])
def test_create_initial_state(box_model):
"""The initial MuJoCo state (qpos, qvel, act) is packed into a flat vector for rollout."""
qpos = np.zeros(box_model.nq)
qvel = np.zeros(box_model.nv)
state = create_initial_state(box_model, qpos, qvel)
expected_size = mujoco.mj_stateSize(
box_model, mujoco.mjtState.mjSTATE_FULLPHYSICS
)
assert state.shape == (expected_size,)
def test_create_initial_state_wrong_qpos(box_model):
"""Wrong-sized qpos is caught early rather than causing a silent rollout bug."""
with pytest.raises(ValueError, match="qpos"):
create_initial_state(box_model, np.zeros(999))
def test_split(sample_trajectory):
"""A long trajectory can be split into smaller chunks for batched optimization."""
traj, *_ = sample_trajectory
chunks = traj.split(chunk_size=1)
assert len(chunks) == 3
assert len(chunks[0].sensordata) == 1
def test_check_compatible_sensor_mismatch(box_model):
"""Mismatched sensor dimensions between data and model are caught before rollout."""
times = np.array([0.0, 0.01, 0.02])
sensordata = timeseries.TimeSeries(times, np.ones((3, 5)))
control = timeseries.TimeSeries(times, np.ones((3, box_model.nu)))
initial_state = np.zeros(
mujoco.mj_stateSize(box_model, mujoco.mjtState.mjSTATE_FULLPHYSICS)
)
traj = SystemTrajectory(
model=box_model,
control=control,
sensordata=sensordata,
initial_state=initial_state,
state=None,
)
with pytest.raises(ValueError, match="Sensor data dimension"):
traj.check_compatible()