e89dae359e
Co-authored-by: Kevin Zakka <kevinarmandzakka@gmail.com>
326 lines
9.8 KiB
Python
326 lines
9.8 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.
|
|
# ==============================================================================
|
|
|
|
"""Common signal modifiers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
|
|
import mujoco
|
|
from mujoco.sysid._src import parameter
|
|
from mujoco.sysid._src import timeseries
|
|
import numpy as np
|
|
|
|
|
|
def _get_sensor_indices(model: mujoco.MjModel, sensor_name: str) -> list[int]:
|
|
sensor_id = mujoco.mj_name2id(
|
|
model, mujoco.mjtObj.mjOBJ_SENSOR.value, sensor_name
|
|
)
|
|
if sensor_id == -1:
|
|
raise ValueError(f"Sensor not found in model: {sensor_name}")
|
|
|
|
addr = model.sensor_adr[sensor_id]
|
|
dim = model.sensor_dim[sensor_id]
|
|
|
|
return list(range(addr, addr + dim))
|
|
|
|
|
|
def get_sensor_indices(
|
|
model: mujoco.MjModel,
|
|
sensor_name: str | list[str],
|
|
sort: bool = False,
|
|
) -> list[int]:
|
|
"""Get sensor indices from a sensor configuration dictionary.
|
|
|
|
Args:
|
|
model: MuJoCo model containing the sensors.
|
|
sensor_name: sensor name or list of names to return the indices for.
|
|
sort: If True, sort the indices before returning.
|
|
|
|
Returns:
|
|
A list of sensor indices.
|
|
"""
|
|
if isinstance(sensor_name, str):
|
|
return _get_sensor_indices(model, sensor_name)
|
|
all_indices = []
|
|
for name in sensor_name:
|
|
all_indices.extend(_get_sensor_indices(model, name))
|
|
if sort:
|
|
return sorted(all_indices)
|
|
return all_indices
|
|
|
|
|
|
def apply_bias(
|
|
ts: timeseries.TimeSeries,
|
|
sensor_name: str,
|
|
bias: parameter.Parameter,
|
|
) -> timeseries.TimeSeries:
|
|
"""Apply a bias to a sensor in a timeseries.
|
|
|
|
Args:
|
|
ts: Input timeseries.
|
|
sensor_name: Name of the sensor to modify.
|
|
bias: Parameter whose ``.value`` is added to the sensor columns.
|
|
"""
|
|
indices = ts.get_indices(sensor_name)[1]
|
|
data_out = ts.data.copy()
|
|
data_out[..., indices] += bias.value
|
|
return timeseries.TimeSeries(ts.times, data_out, ts.signal_mapping)
|
|
|
|
|
|
def apply_gain(
|
|
ts: timeseries.TimeSeries,
|
|
sensor_name: str,
|
|
gain: parameter.Parameter,
|
|
) -> timeseries.TimeSeries:
|
|
"""Apply a gain to a sensor in a timeseries.
|
|
|
|
Args:
|
|
ts: Input timeseries.
|
|
sensor_name: Name of the sensor to modify.
|
|
gain: Parameter whose ``.value`` multiplies the sensor columns.
|
|
"""
|
|
indices = ts.get_indices(sensor_name)[1]
|
|
data_out = ts.data.copy()
|
|
data_out[..., indices] *= gain.value
|
|
return timeseries.TimeSeries(ts.times, data_out, ts.signal_mapping)
|
|
|
|
|
|
def apply_delay(
|
|
ts: timeseries.TimeSeries,
|
|
sensor_name: str,
|
|
delay: parameter.Parameter,
|
|
) -> timeseries.TimeSeries:
|
|
"""Apply a delay to a sensor in a timeseries.
|
|
|
|
Args:
|
|
ts: Input timeseries.
|
|
sensor_name: Name of the sensor to delay.
|
|
delay: Parameter whose ``.value`` is the delay in seconds.
|
|
"""
|
|
indices = ts.get_indices(sensor_name)[1]
|
|
|
|
ts_sensor = timeseries.TimeSeries(
|
|
ts.times, ts.data[:, indices], ts.signal_mapping
|
|
)
|
|
ts_sensor_delayed = ts_sensor.resample(ts.times - delay.value)
|
|
|
|
ts_delayed = timeseries.TimeSeries(ts.times, ts.data, ts.signal_mapping)
|
|
ts_delayed.data[:, indices] = ts_sensor_delayed.data
|
|
|
|
return ts_delayed
|
|
|
|
|
|
def apply_time_window(
|
|
ts: timeseries.TimeSeries,
|
|
min_t: float,
|
|
max_t: float,
|
|
) -> timeseries.TimeSeries:
|
|
"""Select a subset of a timeseries within a time window.
|
|
|
|
The selected timestamps, plus the max delay, can be sampled from ts_sample.
|
|
|
|
Args:
|
|
ts: The timeseries to window.
|
|
min_t: The minimum time.
|
|
max_t: The maximum time.
|
|
|
|
Returns:
|
|
A new timeseries with the timestamps windowed.
|
|
"""
|
|
min_i = np.searchsorted(ts.times, min_t, side="left")
|
|
max_i = np.searchsorted(ts.times, max_t, side="right")
|
|
return timeseries.TimeSeries(
|
|
ts.times[min_i:max_i], ts.data[min_i:max_i], ts.signal_mapping
|
|
)
|
|
|
|
|
|
def apply_delayed_ts_window(
|
|
ts: timeseries.TimeSeries,
|
|
ts_delayed: timeseries.TimeSeries,
|
|
min_delay: float,
|
|
max_delay: float,
|
|
) -> timeseries.TimeSeries:
|
|
"""Window a timeseries to fit within the bounds of a delayed series.
|
|
|
|
Included timestamps must lay within the bounds of a timeseries that may be
|
|
delayed between min_delay and max_delay.
|
|
|
|
Args:
|
|
ts: The timeseries to window.
|
|
ts_delayed: The timeseries to use as the bounds.
|
|
min_delay: The minimum delay. May be negative.
|
|
max_delay: The maximum delay.
|
|
|
|
Returns:
|
|
A new timeseries with the timestamps windowed.
|
|
"""
|
|
if min_delay > max_delay:
|
|
raise ValueError(
|
|
"min_delay must be less than or equal to max_delay, "
|
|
f"received {min_delay} and {max_delay}"
|
|
)
|
|
return apply_time_window(
|
|
ts, ts_delayed.times[0] - min_delay, ts_delayed.times[-1] - max_delay
|
|
)
|
|
|
|
|
|
def _build_per_column_delays(
|
|
ts: timeseries.TimeSeries,
|
|
default_delay: float,
|
|
sensor_delays: dict[str, float] | None,
|
|
predicted_data: bool,
|
|
) -> list[float]:
|
|
"""Build a per-column delay list, shared by both implementations."""
|
|
delays = [default_delay] * ts.data.shape[1]
|
|
if sensor_delays is None:
|
|
sensor_delays = {}
|
|
for name, delay in sensor_delays.items():
|
|
sensor_indices = ts.get_indices(name)[1]
|
|
for i in sensor_indices:
|
|
delays[i] = delay
|
|
if predicted_data:
|
|
delays = [-d for d in delays]
|
|
return delays
|
|
|
|
|
|
def _apply_resample_and_delay_columnwise(
|
|
ts: timeseries.TimeSeries,
|
|
times: np.ndarray,
|
|
delays: list[float],
|
|
) -> np.ndarray:
|
|
"""Reference implementation: resample each column independently."""
|
|
resampled_ts = []
|
|
for i, d in enumerate(delays):
|
|
ts_sliced = timeseries.TimeSeries(
|
|
ts.times, ts.data[:, i : i + 1], ts.signal_mapping
|
|
)
|
|
ts_sliced_resampled = ts_sliced.resample(times + d)
|
|
resampled_ts.append(ts_sliced_resampled)
|
|
return np.concatenate([t.data for t in resampled_ts], axis=1)
|
|
|
|
|
|
_VERIFY_RESAMPLE_GROUPING = False
|
|
|
|
|
|
def apply_resample_and_delay(
|
|
ts: timeseries.TimeSeries,
|
|
times: np.ndarray,
|
|
default_delay: float,
|
|
sensor_delays: dict[str, float] | None = None,
|
|
predicted_data: bool = True,
|
|
) -> timeseries.TimeSeries:
|
|
"""Resample a timeseries and apply per-sensor delays.
|
|
|
|
Args:
|
|
ts: Input timeseries to resample.
|
|
times: Target timestamps.
|
|
default_delay: Default delay applied to all columns.
|
|
sensor_delays: Optional per-sensor delay overrides.
|
|
predicted_data: If True, negate delays (shift predicted to match measured).
|
|
"""
|
|
delays = _build_per_column_delays(
|
|
ts, default_delay, sensor_delays, predicted_data
|
|
)
|
|
|
|
# Group columns by delay value to minimize interpolation calls.
|
|
delay_to_cols: dict[float, list[int]] = {}
|
|
for i, d in enumerate(delays):
|
|
delay_to_cols.setdefault(d, []).append(i)
|
|
|
|
data_out = np.empty((len(times), ts.data.shape[1]))
|
|
for d, cols in delay_to_cols.items():
|
|
group_data = ts.data[:, cols]
|
|
group_ts = timeseries.TimeSeries(ts.times, group_data, ts.signal_mapping)
|
|
resampled = group_ts.resample(times + d)
|
|
data_out[:, cols] = resampled.data
|
|
|
|
if _VERIFY_RESAMPLE_GROUPING:
|
|
reference = _apply_resample_and_delay_columnwise(ts, times, delays)
|
|
np.testing.assert_array_equal(data_out, reference)
|
|
|
|
return timeseries.TimeSeries(times, data_out, ts.signal_mapping)
|
|
|
|
|
|
def prepare_sensor_weights(
|
|
sensor_weights: Mapping[str, float] | np.ndarray,
|
|
n_sensors: int,
|
|
model: mujoco.MjModel,
|
|
) -> np.ndarray:
|
|
"""Prepare sensor weights array from a dict or numpy array.
|
|
|
|
Args:
|
|
sensor_weights: Mapping from sensor name to weight, or a flat array.
|
|
n_sensors: Total number of sensor columns.
|
|
model: MuJoCo model for resolving sensor names to indices.
|
|
"""
|
|
if isinstance(sensor_weights, np.ndarray):
|
|
if sensor_weights.ndim != 1 or sensor_weights.shape[0] != n_sensors:
|
|
raise ValueError(
|
|
"Expected sensor_weights to be a numpy array of shape (n_sensors,), "
|
|
f"received {sensor_weights.shape}"
|
|
)
|
|
return sensor_weights
|
|
else:
|
|
weights = np.ones(n_sensors)
|
|
ids = get_sensor_indices(model, list(sensor_weights.keys()))
|
|
for i, w in zip(ids, sensor_weights.values(), strict=True):
|
|
weights[i] = w
|
|
return weights
|
|
|
|
|
|
def weighted_diff(
|
|
predicted_data: np.ndarray,
|
|
measured_data: np.ndarray,
|
|
model: mujoco.MjModel | None = None,
|
|
sensor_weights: Mapping[str, float] | np.ndarray | None = None,
|
|
) -> np.ndarray:
|
|
"""Compute the weighted difference between measured and predicted data.
|
|
|
|
Calculates ``measured_data - predicted_data``, optionally scaled by sensor
|
|
weights.
|
|
|
|
Args:
|
|
predicted_data: The predicted data, of shape (n_timesteps, n_sensors).
|
|
measured_data: The measured data, of shape (n_timesteps, n_sensors).
|
|
model: Optional mujoco model. This argument is required if sensor_weights
|
|
is not None.
|
|
sensor_weights: An optional dict mapping sensor name to weight. Unspecified
|
|
sensors are assumed to have a weight of 1.
|
|
|
|
Returns:
|
|
A numpy array of the weighted difference.
|
|
"""
|
|
res = measured_data - predicted_data
|
|
if sensor_weights is None:
|
|
return res
|
|
if model is None:
|
|
raise ValueError("model is required if sensor_weights is provided")
|
|
return res * prepare_sensor_weights(sensor_weights, res.shape[-1], model)
|
|
|
|
|
|
def normalize_residual(
|
|
residual: np.ndarray,
|
|
measured_data: np.ndarray,
|
|
) -> np.ndarray:
|
|
"""Normalize the residual by the standard deviation of the measured data.
|
|
|
|
Args:
|
|
residual: Residual array, shape ``(n_timesteps, n_sensors)``.
|
|
measured_data: Measured data array, same shape as *residual*.
|
|
"""
|
|
return residual / (np.linalg.norm(measured_data, axis=0) / np.sqrt(2))
|