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

454 lines
14 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.
# ==============================================================================
"""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
# ---------------------------------------------------------------------------
# compute_all_state_mappings correctness tests
# ---------------------------------------------------------------------------
def _verify_state_mapping(model):
"""Set known qpos/qvel values and verify mappings recover them correctly."""
qpos_map, qvel_map, act_map, _ = TimeSeries.compute_all_state_mappings(model)
data = mujoco.MjData(model)
# Fill with distinct values so misalignment is detectable.
data.qpos[:] = 100 + np.arange(model.nq)
data.qvel[:] = 200 + np.arange(model.nv)
state = np.empty(
mujoco.mj_stateSize(
model, mujoco.mjtState.mjSTATE_FULLPHYSICS.value
)
)
mujoco.mj_getState(
model, data, state, mujoco.mjtState.mjSTATE_FULLPHYSICS.value
)
# Strip the leading time element (mjSTATE_FULLPHYSICS includes time).
state_no_time = state[1:]
# Verify every qpos mapping entry.
for name, (sig_type, indices) in qpos_map.items():
assert sig_type == SignalType.MjStateQPos
values = state_no_time[indices]
# All qpos values should be in [100, 100+nq).
assert np.all(values >= 100) and np.all(values < 100 + model.nq), (
f"{name}: got {values}"
)
# Verify every qvel mapping entry.
for name, (sig_type, indices) in qvel_map.items():
assert sig_type == SignalType.MjStateQVel
values = state_no_time[indices]
# All qvel values should be in [200, 200+nv).
assert np.all(values >= 200) and np.all(values < 200 + model.nv), (
f"{name}: got {values}"
)
# Verify total coverage.
all_qpos_indices = np.concatenate([v[1] for v in qpos_map.values()])
all_qvel_indices = np.concatenate([v[1] for v in qvel_map.values()])
assert len(all_qpos_indices) == model.nq
assert len(all_qvel_indices) == model.nv
assert len(np.unique(all_qpos_indices)) == model.nq, "Duplicate qpos indices"
assert len(np.unique(all_qvel_indices)) == model.nv, "Duplicate qvel indices"
def test_state_mapping_hinge_only():
"""All-hinge model: qposadr == dofadr, so mapping is straightforward."""
xml = """
<mujoco>
<worldbody>
<body><joint name="j1" type="hinge"/><geom size="0.1" mass="1"/>
<body><joint name="j2" type="hinge"/><geom size="0.1" mass="1"/>
</body>
</body>
</worldbody>
</mujoco>
"""
_verify_state_mapping(mujoco.MjModel.from_xml_string(xml))
def test_state_mapping_free_plus_hinge():
"""Free body + hinge joints: jnt_qposadr != jnt_dofadr for the hinges."""
xml = """
<mujoco>
<worldbody>
<body name="box" pos="0 0 1">
<freejoint/>
<geom type="box" size=".1 .1 .1"/>
</body>
<body>
<joint name="h1" type="hinge"/><geom size="0.1" mass="1"/>
<body>
<joint name="h2" type="hinge"/><geom size="0.1" mass="1"/>
</body>
</body>
</worldbody>
</mujoco>
"""
_verify_state_mapping(mujoco.MjModel.from_xml_string(xml))
def test_state_mapping_two_free_bodies():
"""Two free bodies: body_dofadr != jnt_qposadr for the second body."""
xml = """
<mujoco>
<worldbody>
<body name="a" pos="0 0 1">
<freejoint/><geom type="box" size=".1 .1 .1"/>
</body>
<body name="b" pos="1 0 1">
<freejoint/><geom type="box" size=".1 .1 .1"/>
</body>
</worldbody>
</mujoco>
"""
_verify_state_mapping(mujoco.MjModel.from_xml_string(xml))
def test_state_mapping_ball_plus_hinge():
"""Ball joint + hinge: ball takes 4 qpos / 3 qvel, offsetting the hinge."""
xml = """
<mujoco>
<worldbody>
<body>
<joint name="ball" type="ball"/><geom size="0.1" mass="1"/>
<body>
<joint name="h" type="hinge"/><geom size="0.1" mass="1"/>
</body>
</body>
</worldbody>
</mujoco>
"""
_verify_state_mapping(mujoco.MjModel.from_xml_string(xml))