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

302 lines
10 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.
# ==============================================================================
"""Declarative signal transformation for system identification residuals."""
from __future__ import annotations
from collections.abc import Mapping
from fnmatch import fnmatch
import mujoco
from mujoco.sysid._src import parameter
from mujoco.sysid._src import signal_modifier
from mujoco.sysid._src import timeseries
import numpy as np
class SignalTransform:
"""Declarative signal transformation replacing boilerplate modify_residual callbacks.
Usage::
transform = SignalTransform()
transform.delay("*_pos", params["delay_pos"])
transform.delay("*_torque", params["delay_torque"])
transform.gain("*_torque", params["torque_scale"], target="predicted")
transform.enable_sensors(cfg.sensors_enabled)
The ``apply`` method has the same signature as ``ModifyResidualFn`` and can
be passed directly to ``build_residual_fn(signal_transform=transform)``.
"""
def __init__(self, normalize: bool = True):
self._delays: list[tuple[str, str, parameter.Parameter]] = []
self._gains: list[tuple[str, str, str]] = []
self._biases: list[tuple[str, str, str]] = []
self._enabled_sensors: list[str] | None = None
self._sensor_weights: Mapping[str, float] | None = None
self.normalize = normalize
def delay(self, pattern: str, param: parameter.Parameter) -> None:
"""Register a delay for sensors matching *pattern* (fnmatch).
Args:
pattern: fnmatch pattern matched against sensor names.
param: Parameter whose ``.value`` is the delay in seconds.
"""
self._delays.append((pattern, param.name, param))
def gain(
self,
pattern: str,
param: parameter.Parameter,
target: str = "both",
) -> None:
"""Register a multiplicative gain for sensors matching *pattern*.
Args:
pattern: fnmatch pattern matched against sensor names.
param: Parameter whose ``.value`` is the gain factor.
target: One of ``"predicted"``, ``"measured"``, or ``"both"``.
"""
if target not in ("predicted", "measured", "both"):
raise ValueError(
f"target must be 'predicted', 'measured', or 'both', got {target!r}"
)
self._gains.append((pattern, param.name, target))
def bias(
self,
pattern: str,
param: parameter.Parameter,
target: str = "both",
) -> None:
"""Register an additive bias for sensors matching *pattern*.
Args:
pattern: fnmatch pattern matched against sensor names.
param: Parameter whose ``.value`` is the additive bias.
target: One of ``"predicted"``, ``"measured"``, or ``"both"``.
"""
if target not in ("predicted", "measured", "both"):
raise ValueError(
f"target must be 'predicted', 'measured', or 'both', got {target!r}"
)
self._biases.append((pattern, param.name, target))
def enable_sensors(self, sensor_names: list[str]) -> None:
"""Only include these sensors in the returned residual/timeseries.
Args:
sensor_names: Sensor names to keep in the output.
"""
self._enabled_sensors = list(sensor_names)
def set_sensor_weights(self, weights: Mapping[str, float]) -> None:
"""Set per-sensor weights for the weighted diff.
Args:
weights: Mapping from sensor name to weight.
"""
self._sensor_weights = weights
# Private methods.
def _resolve_delays(
self,
sensor_names: list[str],
params: parameter.ParameterDict,
) -> dict[str, float]:
"""Resolve delay patterns to concrete sensor name -> delay value (last match wins)."""
resolved: dict[str, float] = {}
for pattern, param_name, _ in self._delays:
delay_value = params[param_name].value[0]
for name in sensor_names:
if fnmatch(name, pattern):
resolved[name] = delay_value
return resolved
def _compute_delay_bounds(self) -> tuple[float, float]:
"""Compute min/max delay across all registered delay params (deduplicated by name)."""
if not self._delays:
return 0.0, 0.0
seen: set[str] = set()
min_vals: list[float] = []
max_vals: list[float] = []
for _, param_name, param in self._delays:
if param_name in seen:
continue
seen.add(param_name)
min_vals.append(float(param.min_value[0]))
max_vals.append(float(param.max_value[0]))
return min(min_vals), max(max_vals)
def _get_sensor_names(self, ts: timeseries.TimeSeries) -> list[str]:
"""Extract sensor names from a TimeSeries signal_mapping."""
if ts.signal_mapping is None:
return []
return list(ts.signal_mapping.keys())
def _apply_gains_biases_reference(
self,
ts: timeseries.TimeSeries,
target_label: str,
params: parameter.ParameterDict,
) -> timeseries.TimeSeries:
"""Reference implementation: one full copy per gain/bias application."""
sensor_names = self._get_sensor_names(ts)
for pattern, param_name, target in self._gains:
if target != target_label and target != "both":
continue
for name in sensor_names:
if fnmatch(name, pattern):
ts = signal_modifier.apply_gain(ts, name, params[param_name])
for pattern, param_name, target in self._biases:
if target != target_label and target != "both":
continue
for name in sensor_names:
if fnmatch(name, pattern):
ts = signal_modifier.apply_bias(ts, name, params[param_name])
return ts
_VERIFY_GAINS_BIASES = False
def _apply_gains_biases(
self,
ts: timeseries.TimeSeries,
target_label: str,
params: parameter.ParameterDict,
) -> timeseries.TimeSeries:
"""Apply matching gains and biases to a timeseries for the given target label."""
sensor_names = self._get_sensor_names(ts)
data = ts.data.copy()
for pattern, param_name, target in self._gains:
if target != target_label and target != "both":
continue
for name in sensor_names:
if fnmatch(name, pattern):
indices = ts.get_indices(name)[1]
data[..., indices] *= params[param_name].value
for pattern, param_name, target in self._biases:
if target != target_label and target != "both":
continue
for name in sensor_names:
if fnmatch(name, pattern):
indices = ts.get_indices(name)[1]
data[..., indices] += params[param_name].value
result = timeseries.TimeSeries(ts.times, data, ts.signal_mapping)
if self._VERIFY_GAINS_BIASES:
ref = self._apply_gains_biases_reference(ts, target_label, params)
np.testing.assert_array_equal(result.data, ref.data)
return result
def apply(
self,
params: parameter.ParameterDict,
sensordata_predicted: timeseries.TimeSeries,
sensordata_measured: timeseries.TimeSeries,
model: mujoco.MjModel,
return_pred_all: bool,
state: np.ndarray | None = None,
sensor_weights: Mapping[str, float] | None = None,
) -> tuple[np.ndarray, timeseries.TimeSeries, timeseries.TimeSeries]:
"""Apply all registered transforms and compute the residual.
Signature matches :data:`ModifyResidualFn` so this method can be passed
directly as ``modify_residual`` to :func:`model_residual`.
Pipeline: window measured data, resample + delay predicted data, apply
gains/biases, weighted diff, normalise, slice to enabled sensors.
Args:
params: Current parameter dictionary.
sensordata_predicted: Predicted sensor TimeSeries from rollout.
sensordata_measured: Measured sensor TimeSeries (ground truth).
model: MuJoCo model for sensor metadata lookup.
return_pred_all: Whether to return all predicted signals.
state: Unused; part of the ModifyResidualFn signature.
sensor_weights: Per-sensor weighting for the residual.
Returns:
``(residual_array, predicted_ts, measured_ts)`` — the residual matrix
and the (possibly sliced) predicted/measured TimeSeries.
"""
del state # Part of ModifyResidualFn signature but unused here.
sensor_names = self._get_sensor_names(sensordata_predicted)
# 1. Resolve delays and compute bounds.
sensor_delays = self._resolve_delays(sensor_names, params)
min_delay, max_delay = self._compute_delay_bounds()
# 2. Window measured data.
sensordata_measured = signal_modifier.apply_delayed_ts_window(
sensordata_measured, sensordata_predicted, min_delay, max_delay
)
# 3. Resample and delay predicted data.
if sensor_delays:
sensordata_predicted = signal_modifier.apply_resample_and_delay(
sensordata_predicted,
sensordata_measured.times,
0.0,
sensor_delays=sensor_delays,
)
else:
sensordata_predicted = sensordata_predicted.resample(
sensordata_measured.times
)
# 4. Apply gains and biases.
sensordata_predicted = self._apply_gains_biases(
sensordata_predicted, "predicted", params
)
sensordata_measured = self._apply_gains_biases(
sensordata_measured, "measured", params
)
# 5. Weighted diff.
weights = sensor_weights or self._sensor_weights
res = signal_modifier.weighted_diff(
predicted_data=sensordata_predicted.data,
measured_data=sensordata_measured.data,
model=model,
sensor_weights=weights,
)
# 6. Normalize.
if self.normalize:
res = signal_modifier.normalize_residual(res, sensordata_measured.data)
# 7. Slice to enabled sensors.
if not return_pred_all and self._enabled_sensors is not None:
indices = signal_modifier.get_sensor_indices(model, self._enabled_sensors)
sensordata_predicted = timeseries.TimeSeries(
sensordata_predicted.times,
sensordata_predicted.data[:, indices],
)
sensordata_measured = timeseries.TimeSeries(
sensordata_measured.times,
sensordata_measured.data[:, indices],
)
res = res[:, indices]
return res, sensordata_predicted, sensordata_measured