146a5c08f7
This resulted from a lengthy collaboration with @kevinzakka, @jonathanembleyriches, @nimrod-gileadi, @gizemozd, @quagla, and @yuval.
638 lines
24 KiB
Markdown
638 lines
24 KiB
Markdown
# Practical System Identification
|
||
|
||
A toolbox for system identification built on top of MuJoCo.
|
||
|
||
## API Overview
|
||
|
||
The library solves a **box-constrained nonlinear least-squares** problem. Given
|
||
a parameter vector `θ`, simulated sensor readings `ȳ(θ)`, and recorded sensor
|
||
data `y`, the objective is:
|
||
|
||
```
|
||
min ½ ‖W (ȳ(θ) − y)‖²
|
||
θ
|
||
|
||
subject to θ_min ≤ θ ≤ θ_max
|
||
```
|
||
|
||
where `W` is a diagonal weighting matrix and the box constraints enforce
|
||
physical plausibility (e.g., positive masses).
|
||
|
||
The optimizer uses the **Gauss-Newton** method. The residual Jacobian
|
||
`J = ∂r/∂θ` is computed by **finite differences**: each column of `J` requires
|
||
one perturbed simulation rollout, and these evaluations are independent across
|
||
parameters and parallelize naturally across threads. The Gauss-Newton
|
||
approximate Hessian is `H ≈ JᵀJ` and the gradient is `g = Jᵀr`, yielding the
|
||
update `Δθ = −H⁻¹g`. Box constraints are handled by projected steps.
|
||
|
||
The pipeline has five stages:
|
||
|
||
```
|
||
Define Parameters ──> Package Data ──> Build Residual ──> Optimize ──> Save / Report
|
||
ParameterDict ModelSequences build_residual_fn optimize save_results
|
||
```
|
||
|
||
---
|
||
|
||
### What Can You Identify?
|
||
|
||
**Anything settable on `MjSpec` can be identified** via modifier callbacks. The
|
||
convenience functions handle common cases with correct bounds; for everything
|
||
else, write a `modifier` lambda that sets the quantity on the spec.
|
||
|
||
**Physics parameters** — these change the model before simulation:
|
||
|
||
| Target | Approach |
|
||
|---|---|
|
||
| Body mass | `body_inertia_param(..., InertiaType.Mass)` |
|
||
| Body mass + center of mass | `body_inertia_param(..., InertiaType.MassIpos)` |
|
||
| Full body inertia (10-D) | `body_inertia_param(..., InertiaType.Pseudo)` |
|
||
| Actuator P/D gains | `Parameter(..., modifier=lambda s, p: apply_pgain(s, "act1", p.value[0]))` |
|
||
| Contact friction / solref | `Parameter(..., modifier=lambda s, p: s.pair("cp").friction.__setitem__(0, p.value[0]))` |
|
||
| Joint damping / stiffness | `Parameter(..., modifier=lambda s, p: setattr(s.joint("j1"), "damping", p.value[0]))` |
|
||
|
||
**Measurement parameters** — real sensors aren't perfect. They may lag behind
|
||
the simulation clock, have an unknown scale factor, or sit at a nonzero offset.
|
||
These can't be set on `MjSpec` because they aren't physics — they're artifacts
|
||
of the measurement system. `SignalTransform` (Section 4) adjusts the simulated
|
||
or recorded signals *after* rollout to account for these:
|
||
|
||
| Target | Approach |
|
||
|---|---|
|
||
| Sensor delay | `transform.delay("*_pos", params["delay"])` |
|
||
| Sensor gain/scale | `transform.gain("*_torque", params["scale"])` |
|
||
| Sensor bias/offset | `transform.bias("*_vel", params["bias"])` |
|
||
|
||
#### Common recipes
|
||
|
||
**Identify link masses of a robot arm:**
|
||
|
||
```python
|
||
from mujoco.sysid import body_inertia_param, InertiaType, ParameterDict
|
||
|
||
params = ParameterDict()
|
||
for link in ["link1", "link2", "link3"]:
|
||
params.add(body_inertia_param(spec, model, link, inertia_type=InertiaType.Mass))
|
||
```
|
||
|
||
**Identify contact friction:**
|
||
|
||
```python
|
||
from mujoco.sysid import Parameter
|
||
|
||
params.add(Parameter(
|
||
"floor_friction",
|
||
nominal=1.0, min_value=0.1, max_value=3.0,
|
||
modifier=lambda s, p: s.pair("foot_floor").friction.__setitem__(0, p.value[0]),
|
||
))
|
||
```
|
||
|
||
---
|
||
|
||
### 1. Define Parameters
|
||
|
||
A **`Parameter`** is a named value (scalar or array) with bounds and an optional
|
||
**modifier callback** that knows how to apply itself to a MuJoCo spec. A
|
||
**`ParameterDict`** collects parameters into the single vector that the
|
||
optimizer sees — it handles flattening them into one array, writing optimizer
|
||
updates back, and enforcing bounds:
|
||
|
||
```python
|
||
from mujoco.sysid import Parameter, ParameterDict
|
||
|
||
params = ParameterDict()
|
||
|
||
params.add(Parameter(
|
||
"box_mass",
|
||
nominal=5.0, # starting value
|
||
min_value=1.0,
|
||
max_value=10.0,
|
||
modifier=lambda spec, p: setattr(spec.body("box"), "mass", p.value[0]),
|
||
))
|
||
|
||
params.add(Parameter(
|
||
"friction",
|
||
nominal=[1.6, 0.005],
|
||
min_value=[0.0, 0.0],
|
||
max_value=[3.0, 0.01],
|
||
frozen=True, # excluded from optimization
|
||
modifier=lambda spec, p: spec.pair("contact").friction.__setitem__(slice(0, 2), p.value),
|
||
))
|
||
```
|
||
|
||
**`frozen`**: A frozen parameter is completely invisible to the optimizer — it
|
||
is excluded from `as_vector()`, `update_from_vector()`, `get_bounds()`, and
|
||
`randomize()`. Its modifier callback is also **not called** during
|
||
`apply_param_modifiers`, so the model uses whatever value is already in the XML
|
||
spec for that quantity. The intended workflow: define all parameters you might
|
||
ever want to identify up front, then toggle `frozen` on and off as you
|
||
iteratively narrow which parameters matter.
|
||
|
||
Key `ParameterDict` methods:
|
||
|
||
| Method | Description |
|
||
|---|---|
|
||
| `as_vector()` | Flatten all non-frozen parameters into a 1-D array |
|
||
| `update_from_vector(x)` | Write a flat array back into the parameters |
|
||
| `get_bounds()` | Returns `(lower, upper)` bound arrays |
|
||
| `randomize(rng)` | Sample each non-frozen parameter uniformly within bounds |
|
||
| `reset()` | Restore every parameter to its nominal value |
|
||
| `copy()` | Deep copy (preserves modifier lambdas) |
|
||
| `save_to_disk(path)` / `load_from_disk(path)` | YAML serialization (schema + values) |
|
||
|
||
#### Body inertia parameterization
|
||
|
||
Rigid-body inertia is tricky to identify: mass, center-of-mass, and the rotational
|
||
inertia tensor are coupled, and naively optimizing the 6 independent entries of
|
||
the inertia tensor can produce physically impossible results (e.g. negative
|
||
eigenvalues). The library implements three parameterizations of increasing
|
||
fidelity:
|
||
|
||
| `InertiaType` | Params | What it identifies |
|
||
|---|---|---|
|
||
| `Mass` | 1 | Mass only. Optionally scales the existing rotational inertia proportionally (`scale_rot_inertia=True`). |
|
||
| `MassIpos` | 4 | Mass + center-of-mass position (3-D). Optionally scales rotational inertia. |
|
||
| `Pseudo` | 10 | Full inertia via the pseudo-inertia Cholesky factor from [Rucker & Wensing 2022](https://ieeexplore.ieee.org/document/9690029). The 10 parameters `θ = [α, d₁, d₂, d₃, s₁₂, s₂₃, s₁₃, t₁, t₂, t₃]` are the entries of a lower-triangular matrix whose product `LLᵀ` is the 4×4 pseudo-inertia matrix. Physical consistency (positive mass, positive-definite inertia tensor) is guaranteed by construction for any `θ`. |
|
||
|
||
Use `body_inertia_param` to create a `Parameter` with the right nominal values,
|
||
bounds, and modifier already wired up:
|
||
|
||
```python
|
||
from mujoco.sysid import body_inertia_param, InertiaType
|
||
|
||
param = body_inertia_param(
|
||
spec, model, "link1",
|
||
inertia_type=InertiaType.Pseudo,
|
||
)
|
||
params.add(param)
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Package Data
|
||
|
||
Measured data is stored as **`TimeSeries`** objects (frozen dataclass: `times`,
|
||
`data`, optional `signal_mapping`):
|
||
|
||
```python
|
||
from mujoco.sysid import TimeSeries, SignalType
|
||
|
||
# For sensor/state observations:
|
||
sensordata = TimeSeries.from_names(times, sensor_data, model) # all sensors
|
||
sensordata = TimeSeries.from_names(times, data, model, names=["joint1_pos", "joint2_pos"])
|
||
|
||
# Explicit type disambiguation (useful when sensor/state names overlap):
|
||
sensordata = TimeSeries.from_names(times, data, model, names=[
|
||
("joint1_pos", SignalType.MjSensor), # sensor named "joint1_pos"
|
||
("joint1_qpos", SignalType.MjStateQPos), # joint state
|
||
])
|
||
|
||
# For control signals:
|
||
control = TimeSeries.from_control_names(times, control_data, model) # all actuators
|
||
control = TimeSeries.from_control_names(times, data, model, names=["motor1_ctrl"])
|
||
|
||
# For custom/raw data:
|
||
ts = TimeSeries.from_custom_map(times, data, ["signal1", "signal2"])
|
||
```
|
||
|
||
`signal_mapping` is a dict `{name: (SignalType, indices)}` that labels which
|
||
columns of `data` correspond to which sensor/actuator.
|
||
|
||
**`TimeSeries` factory methods:**
|
||
|
||
| Constructor | Use case |
|
||
|---|---|
|
||
| `TimeSeries.from_names(times, data, model, names=None)` | Sensor/state data. If `names=None`, maps all model sensors. |
|
||
| `TimeSeries.from_control_names(times, data, model, names=None)` | Control signals. If `names=None`, maps all actuators. |
|
||
| `TimeSeries.from_custom_map(times, data, signals)` | Custom data with explicit signal definitions. |
|
||
| `TimeSeries(times, data)` | Raw arrays, no signal mapping. |
|
||
| `TimeSeries(times, data, signal_mapping)` | Named signals with explicit mapping. |
|
||
|
||
**`TimeSeries` methods:** `resample(new_times=, target_dt=)`, `interpolate(t)`,
|
||
`get(t)`, `save_to_disk(path)`, `load_from_disk(path)`, `dt_statistics()`,
|
||
`remove_from_beginning(t)`, `slice_by_name(ts, names)`.
|
||
|
||
Bundle a spec with one or more data sequences into a **`ModelSequences`**:
|
||
|
||
```python
|
||
from mujoco.sysid import ModelSequences, create_initial_state
|
||
|
||
initial_state = create_initial_state(model, qpos, qvel, act)
|
||
|
||
ms = ModelSequences(
|
||
name="robot",
|
||
spec=spec,
|
||
sequence_name=["traj_1", "traj_2"], # or a single string
|
||
initial_state=[initial_state_1, initial_state_2],
|
||
control=[control_1, control_2],
|
||
sensordata=[sensordata_1, sensordata_2],
|
||
)
|
||
```
|
||
|
||
You can pass a single sequence (not wrapped in a list) and it will be
|
||
auto-promoted.
|
||
|
||
**Multiple `ModelSequences`:** Each `ModelSequences` carries its own `spec`, but
|
||
the optimizer applies the **same parameter vector `θ`** to all of them. This
|
||
enables joint optimization across different physical configurations. For example,
|
||
you might have the same robot arm recorded with and without a known payload
|
||
attached — two different specs (one has the payload body), two sets of recorded
|
||
data, but the inertial parameters of the arm links are shared. The residuals
|
||
from all `ModelSequences` are stacked and minimized jointly, giving a better-
|
||
conditioned problem than fitting each dataset independently.
|
||
|
||
---
|
||
|
||
### 3. Build the Residual Function
|
||
|
||
The **residual** is the vector of differences between simulated sensor readings
|
||
and recorded sensor data: `r(θ) = W(ȳ(θ) − y)`. Each element measures how
|
||
much the simulation with parameters `θ` disagrees with reality for one sensor
|
||
at one timestep. The optimizer's job is to find the `θ` that makes this vector
|
||
as small as possible (in the least-squares sense).
|
||
|
||
**`build_residual_fn`** captures data and configuration, returning a closure
|
||
that the optimizer will call repeatedly:
|
||
|
||
```python
|
||
from mujoco.sysid import build_residual_fn
|
||
|
||
residual_fn = build_residual_fn(
|
||
models_sequences=[ms],
|
||
# Optional overrides:
|
||
modify_residual=..., # custom residual logic
|
||
custom_rollout=..., # custom simulation
|
||
sensor_weights=..., # per-sensor weighting
|
||
enabled_observations=..., # subset of sensors to use
|
||
)
|
||
```
|
||
|
||
#### How `residual_fn` works internally
|
||
|
||
The returned `residual_fn(x, params)` accepts `x` as either a **1-D vector**
|
||
(plain function evaluation) or a **2-D matrix** of shape `(n_params, n_fd)`
|
||
(batched finite-difference Jacobian evaluation, where each column is a
|
||
perturbed parameter vector). This is the key to parallelism.
|
||
|
||
For each column `i` of `x`:
|
||
|
||
1. `params.update_from_vector(x[:, i])` — writes the optimizer's current
|
||
candidate values back into the `Parameter` objects so that each
|
||
parameter's `.value` attribute reflects column `i` of `x`
|
||
2. `model_i = apply_param_modifiers(params, spec)` — iterates over every
|
||
non-frozen parameter and calls its `modifier(spec, param)` callback,
|
||
then compiles the mutated spec into an `MjModel`
|
||
3. Replicate `model_i` once per trajectory chunk (if you have `C` data
|
||
sequences, you get `C` copies)
|
||
|
||
This produces a flat list of `n_fd * C` models. All of them are rolled out in
|
||
a **single call** to `mujoco.rollout.rollout`:
|
||
|
||
```python
|
||
datas = [mujoco.MjData(models[0]) for _ in range(n_threads)] # one per thread
|
||
|
||
state, sensordata = mujoco.rollout.rollout(
|
||
models, # n_fd * C models
|
||
datas, # K thread-local scratch MjData objects
|
||
initial_states, # n_fd * C initial states
|
||
control, # n_fd * C control sequences
|
||
)
|
||
```
|
||
|
||
MuJoCo's rollout engine distributes the `n_fd * C` independent rollouts across
|
||
`K` threads using the `MjData` objects as thread-local scratch space (each
|
||
thread gets its own `MjData` to avoid data races). **This is why the Jacobian
|
||
computation is fast**: all `n_params + 1` perturbed rollouts (times `C`
|
||
trajectory chunks) execute in one batched, multithreaded call.
|
||
|
||
After rollout, residuals are computed per-trajectory (predicted vs. measured
|
||
sensor data), then stacked and returned.
|
||
|
||
#### Concrete example
|
||
|
||
Suppose you have `p = 10` parameters and `C = 3` trajectory chunks:
|
||
|
||
- **Function eval** (`x` is 1-D): `1 * 3 = 3` rollouts, distributed across
|
||
threads.
|
||
- **Jacobian eval** (`x` is `(10, 11)` — nominal + 10 perturbations): `11 * 3
|
||
= 33` rollouts in one batched call. On a 16-core machine this is ~2x wall
|
||
time of a single rollout.
|
||
|
||
#### Three tiers of customization
|
||
|
||
| Tier | What you provide | When to use |
|
||
|---|---|---|
|
||
| **Default** | Nothing extra (or `SignalTransform`) | Standard MuJoCo sensors, optional delays/gains |
|
||
| **Custom rollout** | `custom_rollout=fn` | Non-standard simulation (e.g. task-space control) |
|
||
| **Custom residual** | `modify_residual=fn` | State-based observations, exotic loss functions |
|
||
|
||
---
|
||
|
||
### 4. SignalTransform (Declarative Residual Configuration)
|
||
|
||
After simulation, the residual pipeline compares predicted sensor readings to
|
||
recorded data. But real sensors aren't ideal — position encoders may lag by a
|
||
few milliseconds, torque sensors may have an unknown scale factor, and velocity
|
||
estimates may sit at a nonzero offset. These aren't physics parameters (you
|
||
can't set "delay" on an `MjSpec`), so they need to be corrected *after* the
|
||
rollout, before the residual is computed.
|
||
|
||
**`SignalTransform`** lets you declare these corrections and which sensors to
|
||
use, without writing a custom residual callback. Internally it:
|
||
|
||
1. **Time-shifts** the predicted (or measured) signals by per-sensor delay
|
||
parameters, resampling onto a common time grid.
|
||
2. **Scales** sensor columns by gain parameters (`target="predicted"` scales
|
||
the simulation output, `target="measured"` scales the recording — useful
|
||
when the sensor's scale factor is unknown on either side).
|
||
3. **Offsets** sensor columns by bias parameters.
|
||
4. Computes the weighted difference and normalizes by RMS.
|
||
|
||
Patterns use `fnmatch` syntax, so `"*_pos"` matches all sensors whose name
|
||
ends in `_pos`:
|
||
|
||
```python
|
||
from mujoco.sysid import SignalTransform
|
||
|
||
transform = SignalTransform()
|
||
transform.delay("*_pos", params["delay_pos"]) # fnmatch pattern
|
||
transform.delay("*_torque", params["delay_torque"])
|
||
transform.gain("*_torque", params["torque_scale"], target="predicted")
|
||
transform.bias("*_vel", params["vel_bias"])
|
||
transform.enable_sensors(["joint1_pos", "joint2_pos", "joint1_torque"])
|
||
transform.set_sensor_weights({"joint1_torque": 0.5})
|
||
|
||
residual_fn = build_residual_fn(
|
||
models_sequences=[ms],
|
||
modify_residual=transform.apply, # drop-in replacement
|
||
)
|
||
```
|
||
|
||
`SignalTransform.apply` has the same signature as `ModifyResidualFn`, so it
|
||
plugs directly into `build_residual_fn`.
|
||
|
||
#### What this replaces
|
||
|
||
Without `SignalTransform`, you'd write the same logic by hand as a
|
||
`modify_residual` callback using the low-level `signal_modifier` functions
|
||
(Section 8):
|
||
|
||
```python
|
||
from mujoco.sysid._src import signal_modifier
|
||
|
||
def modify_residual(params, predicted, measured, model, return_pred_all, **kw):
|
||
# 1. Apply delays and resample onto a common time grid.
|
||
min_d, max_d = -0.02, 0.05 # must track delay bounds yourself
|
||
measured = signal_modifier.apply_delayed_ts_window(measured, predicted, min_d, max_d)
|
||
sensor_delays = {"joint1_pos": params["delay_pos"].value[0], ...}
|
||
predicted = signal_modifier.apply_resample_and_delay(
|
||
predicted, measured.times, default_delay=0.0, sensor_delays=sensor_delays,
|
||
)
|
||
# 2. Apply gains and biases.
|
||
predicted = signal_modifier.apply_gain(predicted, "joint1_torque", params["torque_scale"])
|
||
predicted = signal_modifier.apply_bias(predicted, "joint1_vel", params["vel_bias"])
|
||
# 3. Compute residual.
|
||
diff = signal_modifier.weighted_diff(predicted.data, measured.data, model, weights)
|
||
diff = signal_modifier.normalize_residual(diff, measured.data)
|
||
return diff, predicted, measured
|
||
```
|
||
|
||
`SignalTransform` does all of this — including tracking delay bounds, expanding
|
||
fnmatch patterns to sensor names, and handling the windowing/resampling
|
||
bookkeeping — from a few declarative lines.
|
||
|
||
---
|
||
|
||
### 5. Optimize
|
||
|
||
**`optimize`** runs box-constrained Gauss-Newton least-squares on the residual
|
||
function:
|
||
|
||
```python
|
||
from mujoco.sysid import optimize
|
||
|
||
opt_params, opt_result = optimize(
|
||
initial_params=params,
|
||
residual_fn=residual_fn,
|
||
optimizer="mujoco", # "mujoco", "scipy", or "scipy_parallel_fd"
|
||
max_iters=200,
|
||
)
|
||
```
|
||
|
||
#### Optimizer backends
|
||
|
||
| Backend | Jacobian | Description |
|
||
|---|---|---|
|
||
| `"mujoco"` (recommended) | Parallel FD, batched | `mujoco.minimize.least_squares`. Calls `residual_fn(x)` with `x` as a 2-D matrix `(n_params, n_params+1)` — the nominal point plus one perturbation per parameter — so the entire Jacobian is computed in a single batched, multithreaded rollout call. |
|
||
| `"scipy"` | Sequential 2-point FD | `scipy.optimize.least_squares`. Computes the Jacobian column-by-column (sequential). Slower for problems with many parameters. |
|
||
| `"scipy_parallel_fd"` | Parallel FD via MuJoCo, scipy outer loop | Scipy's trust-region solver but with `mujoco.minimize.jacobian_fd` for the Jacobian. Gives scipy's convergence control with MuJoCo's batched FD speed. |
|
||
|
||
All three backends solve box-constrained nonlinear least-squares using the
|
||
Gauss-Newton Hessian approximation `H ≈ JᵀJ`. They differ in how the
|
||
finite-difference Jacobian is computed and how the trust-region step is
|
||
handled: `"mujoco"` uses projected Gauss-Newton steps, while `"scipy"` and
|
||
`"scipy_parallel_fd"` use scipy's trust-region reflective algorithm (`trf`).
|
||
|
||
#### Return value
|
||
|
||
Returns `(opt_params, OptimizeResult)` where `opt_params` is a deep copy of
|
||
the input `ParameterDict` with `.value` set to the solution, and
|
||
`OptimizeResult` contains:
|
||
- `.x` — the solution vector
|
||
- `.jac` — the Jacobian at the solution (used for confidence intervals)
|
||
- `.grad` — the gradient at the solution
|
||
- `.extras` — (**mujoco backend only**) dict with `"objective"` (cost per
|
||
iteration) and `"candidate"` (parameter vector per iteration), when verbose
|
||
|
||
---
|
||
|
||
### 6. Save Results and Report
|
||
|
||
**`save_results`** writes everything to disk:
|
||
|
||
```python
|
||
from mujoco.sysid import save_results
|
||
|
||
save_results(
|
||
experiment_results_folder="results/exp01",
|
||
models_sequences=[ms],
|
||
initial_params=params,
|
||
opt_params=opt_params,
|
||
opt_result=opt_result,
|
||
residual_fn=residual_fn,
|
||
)
|
||
```
|
||
|
||
This creates:
|
||
- `params_x_0.yaml` — initial parameter values
|
||
- `params_x_hat.yaml` — optimized parameter values
|
||
- `results.pkl` — full `OptimizeResult`
|
||
- `confidence.pkl` — parameter covariance matrix `Σ_θ = σ²_r H⁻¹` and
|
||
per-parameter confidence intervals, computed from the eigendecomposition of
|
||
`H = JᵀJ` at the solution. Parameters in near-null-space directions of `H`
|
||
receive infinite confidence intervals, making identifiability issues
|
||
immediately visible.
|
||
- `{model_name}.xml` — identified MuJoCo XML for each model
|
||
|
||
**`default_report`** generates an HTML report with sensor comparisons, parameter
|
||
tables, and videos:
|
||
|
||
```python
|
||
from mujoco.sysid import default_report
|
||
|
||
default_report(
|
||
models_sequences=[ms],
|
||
initial_params=params,
|
||
opt_params=opt_params,
|
||
opt_result=opt_result,
|
||
residual_fn=residual_fn,
|
||
save_dir="results/exp01",
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
### 7. Model Modification
|
||
|
||
`apply_param_modifiers` is the default `build_model` implementation — it
|
||
iterates over all non-frozen parameters, calls each one's `modifier` callback
|
||
on the spec, and compiles. Most users never need to call it directly; it runs
|
||
automatically inside the residual pipeline.
|
||
|
||
The remaining functions are useful when writing a **custom `build_model`**
|
||
(e.g. the box case study manually mutates the spec instead of using modifier
|
||
callbacks):
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `apply_param_modifiers(params, spec)` | Run all modifier callbacks, return compiled `MjModel` |
|
||
| `apply_param_modifiers_spec(params, spec)` | Run all modifier callbacks, return the `MjSpec` |
|
||
| `apply_pgain(spec, name, value)` | Set proportional gain on a position actuator |
|
||
| `apply_dgain(spec, name, value)` | Set derivative gain on a position actuator |
|
||
| `apply_pdgain(spec, name, value)` | Set both P and D gains |
|
||
| `apply_body_inertia(spec, name, param)` | Apply Mass / MassIpos / Pseudo inertia |
|
||
| `body_inertia_param(spec, model, name, ...)` | Create a `Parameter` for body inertia |
|
||
| `remove_visuals(spec)` | Strip textures, materials, and visual-only geoms |
|
||
|
||
---
|
||
|
||
### 8. Signal Modification (Power-User API)
|
||
|
||
Low-level functions used internally by `SignalTransform` and the default
|
||
residual pipeline. Useful when writing a custom `modify_residual`:
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `get_sensor_indices(model, name)` | Column indices for a named sensor |
|
||
| `apply_gain(ts, name, param)` | Multiply sensor columns by `param.value` |
|
||
| `apply_bias(ts, name, param)` | Add `param.value` to sensor columns |
|
||
| `apply_delay(ts, name, param)` | Time-shift sensor columns |
|
||
| `apply_delayed_ts_window(ts, ts_ref, min_d, max_d)` | Crop `ts` to the valid time window |
|
||
| `apply_resample_and_delay(ts, times, default_delay, ...)` | Resample with per-sensor delays |
|
||
| `weighted_diff(pred, meas, model, weights)` | `measured - predicted`, optionally weighted |
|
||
| `normalize_residual(residual, measured)` | Divide by column-wise RMS of measured data |
|
||
|
||
---
|
||
|
||
### 9. Additional Utilities
|
||
|
||
| Function / Class | Module | Description |
|
||
|---|---|---|
|
||
| `create_initial_state(model, qpos, qvel, act)` | trajectory | Pack qpos/qvel/act into a flat state vector |
|
||
| `SystemTrajectory` | trajectory | Frozen dataclass holding a single rollout (model, control, sensordata, state) |
|
||
| `sysid_rollout(models, datas, control, initial_states)` | trajectory | Parallel MuJoCo rollout returning `SystemTrajectory` list |
|
||
| `render_rollout(model, data, state, framerate)` | plotting | Render state trajectories to pixel frames |
|
||
| `calculate_intervals(residuals, J, alpha)` | optimize | Confidence intervals from Jacobian at the solution |
|
||
| `sweep_parameter(params, name, values, residual_fn)` | optimize | 1-D parameter sweep returning cost curve |
|
||
| `plot_sensor_comparison(model, ...)` | plotting | Matplotlib overlay of predicted vs. measured sensors |
|
||
| `SignalType` | timeseries | Enum: `MjSensor`, `CustomObs`, `MjStateQPos`, `MjStateQVel`, `MjStateAct`, `MjCtrl` |
|
||
|
||
---
|
||
|
||
## Type Aliases
|
||
|
||
```python
|
||
ModifyResidualFn = Callable[
|
||
..., tuple[np.ndarray, TimeSeries, TimeSeries]
|
||
]
|
||
# (params, sensordata_predicted, sensordata_measured, model, return_pred_all, state=..., sensor_weights=...)
|
||
# Returns (residual_array, pred_timeseries, measured_timeseries)
|
||
|
||
CustomRolloutFn = Callable[..., Sequence[SystemTrajectory]]
|
||
# (models, datas, control_signal, initial_states, param_dicts, ...)
|
||
# Returns list of SystemTrajectory
|
||
|
||
BuildModelFn = Callable[[ParameterDict, MjSpec], MjModel]
|
||
# Default: apply_param_modifiers
|
||
```
|
||
|
||
---
|
||
|
||
## Skeleton Case Study
|
||
|
||
Pseudocode showing the five-stage pipeline. Replace the data-loading step with
|
||
your own hardware logs or simulation data. For a complete runnable example, see
|
||
`case_studies/box/`.
|
||
|
||
```python
|
||
import mujoco
|
||
import numpy as np
|
||
|
||
from mujoco.sysid import (
|
||
Parameter,
|
||
ParameterDict,
|
||
TimeSeries,
|
||
ModelSequences,
|
||
build_residual_fn,
|
||
create_initial_state,
|
||
optimize,
|
||
save_results,
|
||
)
|
||
|
||
# 1. Load model.
|
||
spec = mujoco.MjSpec.from_file("robot.xml")
|
||
model = spec.compile()
|
||
|
||
# 2. Define parameters with modifier callbacks.
|
||
params = ParameterDict()
|
||
params.add(Parameter(
|
||
"link1_mass",
|
||
nominal=2.0,
|
||
min_value=0.5,
|
||
max_value=5.0,
|
||
modifier=lambda spec, p: setattr(spec.body("link1"), "mass", p.value[0]),
|
||
))
|
||
|
||
# 3. Package recorded data.
|
||
# times: (N,) timestamps
|
||
# ctrl_array: (N, model.nu) control inputs
|
||
# sensor_array: (N, model.nsensordata) recorded sensor readings
|
||
# qpos_0, qvel_0: initial joint positions and velocities
|
||
control = TimeSeries.from_control_names(times, ctrl_array, model)
|
||
sensordata = TimeSeries.from_names(times, sensor_array, model)
|
||
initial_state = create_initial_state(model, qpos_0, qvel_0)
|
||
|
||
ms = ModelSequences(
|
||
name="robot",
|
||
spec=spec,
|
||
sequence_name="traj_1",
|
||
initial_state=initial_state,
|
||
control=control,
|
||
sensordata=sensordata,
|
||
)
|
||
|
||
# 4. Build the residual function and optimize.
|
||
# models_sequences is a list because you can jointly optimize across
|
||
# multiple ModelSequences with different specs (see Section 2).
|
||
residual_fn = build_residual_fn(models_sequences=[ms])
|
||
opt_params, opt_result = optimize(
|
||
initial_params=params,
|
||
residual_fn=residual_fn,
|
||
optimizer="mujoco",
|
||
)
|
||
|
||
# 5. Inspect results.
|
||
print(opt_params)
|
||
save_results("results/", [ms], params, opt_params, opt_result, residual_fn)
|
||
```
|