2396a2541c
-- 4cabc9c52dc07ea2d64a3de6dbd58e47a2ad023c by Kevin Zakka <kevinarmandzakka@gmail.com>: Add system identification tutorial and fix template packaging - Add sysid tutorial notebook (sysid.ipynb) - Include HTML templates in package-data to fix TemplateNotFound errors when pip installing mujoco[sysid] COPYBARA_INTEGRATE_REVIEW=https://github.com/google-deepmind/mujoco/pull/3092 from kevinzakka:sysid-notebook 4cabc9c52dc07ea2d64a3de6dbd58e47a2ad023c PiperOrigin-RevId: 868900119 Change-Id: I53c4cc94b5fb6f864c5cee589af9dfad9bfc8744
1420 lines
52 KiB
Plaintext
1420 lines
52 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"# System Identification\n",
|
|
"\n",
|
|
"This notebook describes MuJoCo's [system identification](https://en.wikipedia.org/wiki/System_identification) framework. System identification optimizes parameters to make a simulation match measurements. We introduce the framework's core concepts and walk through some basic examples. More detailed exercises will be added soon.\n",
|
|
"\n",
|
|
"**In this notebook we will:**\n",
|
|
"\n",
|
|
"1. [**Formulation:**](#formulation) briefly introduce the sysid problem\n",
|
|
"2. [**Core Concepts:**](#core-concepts) estimate the mass of a mass-spring-damper\n",
|
|
"3. [**API:**](#api) tour core API concepts like `Parameter`, `TimeSeries`, and `ModelSequences`\n",
|
|
"4. [**Robot arm:**](#robot-arm) identify joint armature on a 5-DOF arm, with confidence intervals, sensor plots, and an interactive HTML report\n",
|
|
"5. [**Parameter Identifiability:**](#ambiguity) understand and diagnose when parameters are unidentifiable"
|
|
],
|
|
"metadata": {
|
|
"id": "drs7G4CAj9Nx"
|
|
},
|
|
"id": "drs7G4CAj9Nx"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"# Setup"
|
|
],
|
|
"metadata": {
|
|
"id": "8P1e2JXckIbW"
|
|
},
|
|
"id": "8P1e2JXckIbW"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title Install MuJoCo and mediapy\n",
|
|
"!pip install -q mujoco[sysid] --pre -f https://py.mujoco.org/\n",
|
|
"!pip install -q mediapy"
|
|
],
|
|
"metadata": {
|
|
"id": "OAAcG7DqkK3C"
|
|
},
|
|
"id": "OAAcG7DqkK3C",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title Set up GPU rendering\n",
|
|
"from google.colab import files\n",
|
|
"import distutils.util\n",
|
|
"import os\n",
|
|
"import subprocess\n",
|
|
"if subprocess.run('nvidia-smi').returncode:\n",
|
|
" raise RuntimeError(\n",
|
|
" 'Cannot communicate with GPU. '\n",
|
|
" 'Make sure you are using a GPU Colab runtime. '\n",
|
|
" 'Go to the Runtime menu and select Choose runtime type.')\n",
|
|
"\n",
|
|
"# Add an ICD config so that glvnd can pick up the Nvidia EGL driver.\n",
|
|
"# This is usually installed as part of an Nvidia driver package, but the Colab\n",
|
|
"# kernel doesn't install its driver via APT, and as a result the ICD is missing.\n",
|
|
"# (https://github.com/NVIDIA/libglvnd/blob/master/src/EGL/icd_enumeration.md)\n",
|
|
"NVIDIA_ICD_CONFIG_PATH = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json'\n",
|
|
"if not os.path.exists(NVIDIA_ICD_CONFIG_PATH):\n",
|
|
" with open(NVIDIA_ICD_CONFIG_PATH, 'w') as f:\n",
|
|
" f.write(\"\"\"{\n",
|
|
" \"file_format_version\" : \"1.0.0\",\n",
|
|
" \"ICD\" : {\n",
|
|
" \"library_path\" : \"libEGL_nvidia.so.0\"\n",
|
|
" }\n",
|
|
"}\n",
|
|
"\"\"\")"
|
|
],
|
|
"metadata": {
|
|
"id": "SIrVAb4gkMin"
|
|
},
|
|
"id": "SIrVAb4gkMin",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title Configure EGL backend\n",
|
|
"print('Setting environment variable to use GPU rendering:')\n",
|
|
"%env MUJOCO_GL=egl"
|
|
],
|
|
"metadata": {
|
|
"id": "QgGusTkjkNcF"
|
|
},
|
|
"id": "QgGusTkjkNcF",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"import mujoco\n",
|
|
"import mujoco.rollout as rollout\n",
|
|
"from mujoco import sysid\n",
|
|
"import numpy as np\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"import mediapy as media\n",
|
|
"from absl import logging\n",
|
|
"import base64\n",
|
|
"from IPython.display import IFrame\n",
|
|
"\n",
|
|
"logging.set_verbosity(\"INFO\")\n",
|
|
"\n",
|
|
"def display_report(report):\n",
|
|
" html_b64 = base64.b64encode(report.build().encode()).decode()\n",
|
|
" return IFrame(src=f\"data:text/html;base64,{html_b64}\", width=\"100%\", height=800)"
|
|
],
|
|
"metadata": {
|
|
"id": "fWs5EDSRkOfj"
|
|
},
|
|
"id": "fWs5EDSRkOfj",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"<a name=\"formulation\"></a>\n",
|
|
"# Formulation"
|
|
],
|
|
"metadata": {
|
|
"id": "LU9bl8aSkIWp"
|
|
},
|
|
"id": "LU9bl8aSkIWp"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"This library does [**gray-box**](https://en.wikipedia.org/wiki/Grey_box_model) identification: you supply the model structure\n",
|
|
"(rigid-body dynamics, contacts, actuators) via a MuJoCo XML, and the optimizer\n",
|
|
"adjusts the parameters you designate as unknown.\n",
|
|
"\n",
|
|
"Given $K$ parameters collected in a vector $\\theta$ and $N$ sensor\n",
|
|
"measurements $y$, we simulate the model to produce predicted outputs\n",
|
|
"$\\bar y(\\theta)$ and minimize the weighted residual:\n",
|
|
"\n",
|
|
"$$\\min_\\theta \\; \\tfrac{1}{2}\\lVert W\\bigl(\\bar y(\\theta) - y\\bigr)\\rVert^2\n",
|
|
"\\qquad \\text{s.t.}\\quad l \\preccurlyeq \\theta \\preccurlyeq u$$\n",
|
|
"\n",
|
|
"This is a box-constrained **nonlinear least-squares** problem. The optimizer\n",
|
|
"uses a Gauss-Newton / Levenberg-Marquardt algorithm with finite-difference\n",
|
|
"Jacobians. Each parameter perturbation requires an independent simulation\n",
|
|
"rollout, and all of them execute in a single batched call to [`mujoco.rollout`](https://colab.research.google.com/github/google-deepmind/mujoco/blob/main/python/rollout.ipynb),\n",
|
|
"parallelized across CPU threads.\n",
|
|
"\n",
|
|
"For a detailed treatment of the underlying optimizer, see the\n",
|
|
"[Least Squares](https://colab.research.google.com/github/google-deepmind/mujoco/blob/main/python/least_squares.ipynb) notebook."
|
|
],
|
|
"metadata": {
|
|
"id": "pyAquNvkkIUU"
|
|
},
|
|
"id": "pyAquNvkkIUU"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"<a name=\"core-concepts\"></a>\n",
|
|
"# 1. Core Concepts\n",
|
|
"\n",
|
|
"We illustrate the framework's core concepts with an actuated [mass-spring-damper](https://en.wikipedia.org/wiki/Mass-spring-damper_model). We know the exact spring stiffness and damping, but the mass must be estimated."
|
|
],
|
|
"metadata": {
|
|
"id": "wLktJeBokISN"
|
|
},
|
|
"id": "wLktJeBokISN"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### The model\n",
|
|
"\n",
|
|
"A box on a spring, driven by a force actuator, with position and velocity\n",
|
|
"sensors. The true mass is **1.0 kg**."
|
|
],
|
|
"metadata": {
|
|
"id": "_7k6iikqkIP6"
|
|
},
|
|
"id": "_7k6iikqkIP6"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title SPRING_MASS_XML { vertical-output: true}\n",
|
|
"SPRING_MASS_XML = \"\"\"\\\n",
|
|
"<mujoco model=\"spring_mass\">\n",
|
|
" <option timestep=\"0.002\">\n",
|
|
" <flag contact=\"disable\"/>\n",
|
|
" </option>\n",
|
|
" <worldbody>\n",
|
|
" <body name=\"ball\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0\" mass=\"1.0\" diaginertia=\"0.001 0.001 0.001\"/>\n",
|
|
" <joint name=\"slide\" type=\"slide\" axis=\"1 0 0\"\n",
|
|
" stiffness=\"100\" damping=\"5.0\"/>\n",
|
|
" <geom type=\"box\" size=\"0.05 0.05 0.05\"/>\n",
|
|
" </body>\n",
|
|
" </worldbody>\n",
|
|
" <actuator>\n",
|
|
" <motor name=\"push\" joint=\"slide\"/>\n",
|
|
" </actuator>\n",
|
|
" <sensor>\n",
|
|
" <jointpos name=\"position\" joint=\"slide\"/>\n",
|
|
" <jointvel name=\"velocity\" joint=\"slide\"/>\n",
|
|
" </sensor>\n",
|
|
"</mujoco>\n",
|
|
"\"\"\""
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "NvQFiFJhkXSs"
|
|
},
|
|
"id": "NvQFiFJhkXSs",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Generate \"measured\" data\n",
|
|
"\n",
|
|
"We simulate the true model to create our ground-truth sensor recordings.\n",
|
|
"A multi-frequency excitation signal produces a non-trivial trajectory.\n",
|
|
"<!-- ensures the system is well-excited across its dynamics. -->\n",
|
|
"\n",
|
|
"`create_initial_state` packs the joint positions, velocities and actuator\n",
|
|
"activations into the\n",
|
|
"[state vector](https://mujoco.readthedocs.io/en/stable/computation/index.html#the-state)\n",
|
|
"that `mujoco.rollout` expects. The results are wrapped in `TimeSeries`\n",
|
|
"objects, which pair timestamps with data columns and are covered in detail\n",
|
|
"in [Section 2](#api)."
|
|
],
|
|
"metadata": {
|
|
"id": "KMEsYY0hkILb"
|
|
},
|
|
"id": "KMEsYY0hkILb"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"spec = mujoco.MjSpec.from_string(SPRING_MASS_XML)\n",
|
|
"model = spec.compile()\n",
|
|
"data = mujoco.MjData(model)\n",
|
|
"\n",
|
|
"duration = 3.0\n",
|
|
"n_steps = int(duration / model.opt.timestep)\n",
|
|
"t = np.arange(n_steps) * model.opt.timestep\n",
|
|
"\n",
|
|
"ctrl = (5.0 * np.sin(2 * np.pi * 1.5 * t)\n",
|
|
" + 3.0 * np.sin(2 * np.pi * 3.7 * t)).reshape(-1, 1)\n",
|
|
"\n",
|
|
"initial_state = sysid.create_initial_state(model, data.qpos, data.qvel, data.act)\n",
|
|
"\n",
|
|
"state, sensor = rollout.rollout(model, data, initial_state, ctrl[:-1])\n",
|
|
"state = np.squeeze(state, axis=0)\n",
|
|
"sensor = np.squeeze(sensor, axis=0)\n",
|
|
"times = state[:, 0]"
|
|
],
|
|
"metadata": {
|
|
"id": "XXzMhCJTka8Z"
|
|
},
|
|
"id": "XXzMhCJTka8Z",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"Let's visualize the result:"
|
|
],
|
|
"metadata": {
|
|
"id": "_tOP-duuYpVt"
|
|
},
|
|
"id": "_tOP-duuYpVt"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title { vertical-output: true}\n",
|
|
"\n",
|
|
"# Render the rollout.\n",
|
|
"frames = sysid.render_rollout(\n",
|
|
" model, data, state[None], framerate=30, height=400, width=560\n",
|
|
")\n",
|
|
"media.show_video(frames, fps=30)\n",
|
|
"\n",
|
|
"control_ts = sysid.TimeSeries(t, ctrl)\n",
|
|
"sensor_ts = sysid.TimeSeries.from_names(times, sensor, model)\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(3, 1, figsize=(5, 4), sharex=True,\n",
|
|
" gridspec_kw={\"height_ratios\": [2, 2, 1]})\n",
|
|
"\n",
|
|
"axes[0].plot(times, sensor[:, 0], color=\"C0\", linewidth=1.0)\n",
|
|
"axes[0].set_ylabel(\"Position (m)\")\n",
|
|
"axes[0].set_title(\"Measured trajectory (true mass = 1.0 kg)\")\n",
|
|
"\n",
|
|
"axes[1].plot(times, sensor[:, 1], color=\"C1\", linewidth=1.0)\n",
|
|
"axes[1].set_ylabel(\"Velocity (m/s)\")\n",
|
|
"\n",
|
|
"axes[2].plot(t, ctrl[:, 0], color=\"0.4\", linewidth=0.8)\n",
|
|
"axes[2].set_ylabel(\"Control (N)\")\n",
|
|
"axes[2].set_xlabel(\"Time (s)\")\n",
|
|
"\n",
|
|
"for ax in axes:\n",
|
|
" ax.grid(True, alpha=0.3)\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.show()"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "QwLOAPkTYsXG"
|
|
},
|
|
"id": "QwLOAPkTYsXG",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Construct the System Identification Problem\n",
|
|
"\n",
|
|
"#### Define the unknown parameter\n",
|
|
"\n",
|
|
"A `Parameter` wraps a scalar (or vector) value with:\n",
|
|
"- **bounds** for box-constrained optimization\n",
|
|
"- a **modifier** callback that stamps the current value onto an [`MjSpec`](https://mujoco.readthedocs.io/en/stable/python.html#model-editing)\n",
|
|
"\n",
|
|
"The `nominal` value is a fixed reference point (here we set it to the true\n",
|
|
"value for later comparison, but in practice it would be your best prior\n",
|
|
"guess). The mutable `value` is what the optimizer actually updates.\n",
|
|
"\n",
|
|
"We'll start the optimizer at **mass = 2.0 kg**, double the true value."
|
|
],
|
|
"metadata": {
|
|
"id": "8XkPEIaVkIJF"
|
|
},
|
|
"id": "8XkPEIaVkIJF"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"def set_mass(spec, param):\n",
|
|
" \"\"\"Modifier: stamp the current mass value onto the MjSpec.\"\"\"\n",
|
|
" spec.body(\"ball\").mass = param.value[0]\n",
|
|
"\n",
|
|
"params = sysid.ParameterDict()\n",
|
|
"params.add(sysid.Parameter(\n",
|
|
" \"mass\",\n",
|
|
" nominal=1.0,\n",
|
|
" min_value=0.3,\n",
|
|
" max_value=3.0,\n",
|
|
" modifier=set_mass,\n",
|
|
"))\n",
|
|
"\n",
|
|
"params[\"mass\"].value[:] = 2.0\n",
|
|
"print(f\"Starting mass: {params['mass'].value[0]:.2f} kg (true: 1.0 kg)\")"
|
|
],
|
|
"metadata": {
|
|
"id": "s6HVXGVJkfka"
|
|
},
|
|
"id": "s6HVXGVJkfka",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"#### Package data\n",
|
|
"\n",
|
|
"`ModelSequences` bundles an `MjSpec` with one or more measured trajectories\n",
|
|
"(initial state, controls and sensor readings)."
|
|
],
|
|
"metadata": {
|
|
"id": "KjUPZVlOkIEn"
|
|
},
|
|
"id": "KjUPZVlOkIEn"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"ms = sysid.ModelSequences(\n",
|
|
" \"spring_mass\", spec, \"measured\", initial_state, control_ts, sensor_ts,\n",
|
|
")"
|
|
],
|
|
"metadata": {
|
|
"id": "0KxR-RtpkiL3"
|
|
},
|
|
"id": "0KxR-RtpkiL3",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"#### Optimize\n",
|
|
"\n",
|
|
"`build_residual_fn` creates a callable that, for a given parameter vector,\n",
|
|
"applies the parameters to the spec, rolls out the simulation, and returns the\n",
|
|
"difference between predicted and measured sensor data. This difference is the\n",
|
|
"**residual** that the optimizer drives to zero."
|
|
],
|
|
"metadata": {
|
|
"id": "0aQQLaJjY09c"
|
|
},
|
|
"id": "0aQQLaJjY09c"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"residual_fn = sysid.build_residual_fn(models_sequences=[ms])\n",
|
|
"\n",
|
|
"opt_params, opt_result = sysid.optimize(\n",
|
|
" initial_params=params,\n",
|
|
" residual_fn=residual_fn,\n",
|
|
" optimizer=\"mujoco\",\n",
|
|
" verbose=True,\n",
|
|
")\n",
|
|
"\n",
|
|
"print(f\"\\nRecovered mass: {opt_params['mass'].value[0]:.4f} kg (true: 1.0 kg)\")"
|
|
],
|
|
"metadata": {
|
|
"id": "jhQShvjLY2OG"
|
|
},
|
|
"id": "jhQShvjLY2OG",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"#### Report\n",
|
|
"\n",
|
|
"Inspect the report to see that the optimized predictions fit the nominal (ground truth) while the initial sensor predictions do not.\n",
|
|
"\n",
|
|
"The report also contains measurement comparisons, parameter tables, confidence intervals, and other debugging information."
|
|
],
|
|
"metadata": {
|
|
"id": "dSRVGBIzY7is"
|
|
},
|
|
"id": "dSRVGBIzY7is"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"report = sysid.default_report(\n",
|
|
" models_sequences=[ms],\n",
|
|
" initial_params=params,\n",
|
|
" opt_params=opt_params,\n",
|
|
" residual_fn=residual_fn,\n",
|
|
" opt_result=opt_result,\n",
|
|
" title=\"Mass Spring Damper Identification\",\n",
|
|
" generate_videos=False,\n",
|
|
")\n",
|
|
"display_report(report)"
|
|
],
|
|
"metadata": {
|
|
"id": "G7pcHNbSY7F4"
|
|
},
|
|
"id": "G7pcHNbSY7F4",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"<a name=\"api\"></a>\n",
|
|
"# 2. API\n",
|
|
"\n",
|
|
"The warm-up introduced `Parameter`, `TimeSeries`, and `ModelSequences` in\n",
|
|
"passing. Here we look at each one more closely."
|
|
],
|
|
"metadata": {
|
|
"id": "xQo1OQn_kjlA"
|
|
},
|
|
"id": "xQo1OQn_kjlA"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"## ParameterDict\n",
|
|
"\n",
|
|
"In the warm-up we created a single `Parameter` with a name, bounds and a\n",
|
|
"modifier callback. A `ParameterDict` groups multiple parameters for the optimizer.\n",
|
|
"\n",
|
|
"Parameters can be **frozen**: they are excluded from the optimization vector\n",
|
|
"but their modifiers are still applied at the frozen value."
|
|
],
|
|
"metadata": {
|
|
"id": "YvPJxqUOkjjI"
|
|
},
|
|
"id": "YvPJxqUOkjjI"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"# Three parameters for the spring-mass model. Damping is frozen.\n",
|
|
"params = sysid.ParameterDict()\n",
|
|
"params.add(sysid.Parameter(\n",
|
|
" \"mass\", nominal=1.0, min_value=0.3, max_value=3.0,\n",
|
|
" modifier=lambda s, p: setattr(s.body(\"ball\"), \"mass\", p.value[0]),\n",
|
|
"))\n",
|
|
"params.add(sysid.Parameter(\n",
|
|
" \"stiffness\", nominal=100.0, min_value=30.0, max_value=300.0,\n",
|
|
" modifier=lambda s, p: setattr(s.joint(\"slide\"), \"stiffness\", p.value[0]),\n",
|
|
"))\n",
|
|
"params.add(sysid.Parameter(\n",
|
|
" \"damping\", nominal=5.0, min_value=0.0, max_value=20.0, frozen=True,\n",
|
|
" modifier=lambda s, p: setattr(s.joint(\"slide\"), \"damping\", p.value[0]),\n",
|
|
"))\n",
|
|
"\n",
|
|
"print(\"Parameter vector (excludes frozen):\", params.as_vector())\n",
|
|
"print(\"Bounds:\", params.get_bounds())\n",
|
|
"print(\"Frozen 'damping' still in dict: \", params[\"damping\"].value)"
|
|
],
|
|
"metadata": {
|
|
"id": "WqFeNF9NkpRz"
|
|
},
|
|
"id": "WqFeNF9NkpRz",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"## TimeSeries\n",
|
|
"\n",
|
|
"`TimeSeries` is an immutable container for timestamped signals. The\n",
|
|
"`from_names` factory that we used in the warm-up automatically creates a\n",
|
|
"**signal mapping**, a dict from sensor names to column indices. This mapping\n",
|
|
"lets the library match predicted signals to measured ones by name.\n",
|
|
"\n",
|
|
"`TimeSeries` also supports resampling to a different timestep, which is useful\n",
|
|
"when your measured data and simulation run at different rates."
|
|
],
|
|
"metadata": {
|
|
"id": "YlHeA2D1kjgx"
|
|
},
|
|
"id": "YlHeA2D1kjgx"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"# sensor_ts was created in the warm-up with from_names\n",
|
|
"print(\"Signal mapping:\", sensor_ts.signal_mapping)\n",
|
|
"\n",
|
|
"# Resample to a coarser timestep (returns a new TimeSeries)\n",
|
|
"ts_coarse = sensor_ts.resample(target_dt=0.01)\n",
|
|
"print(f\"Original: {len(sensor_ts.times)} pts -> Resampled: {len(ts_coarse.times)} pts\")"
|
|
],
|
|
"metadata": {
|
|
"id": "1Aw6Io43krdq"
|
|
},
|
|
"id": "1Aw6Io43krdq",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"## ModelSequences\n",
|
|
"\n",
|
|
"`ModelSequences` bundles an `MjSpec` with one or more measured trajectories\n",
|
|
"(initial state, controls and sensor readings). It is the input to\n",
|
|
"`build_residual_fn`.\n",
|
|
"\n",
|
|
"You can pass **multiple trajectories** for the same spec. This is the key\n",
|
|
"mechanism for improving parameter identifiability, as we'll see in the next\n",
|
|
"section."
|
|
],
|
|
"metadata": {
|
|
"id": "YP79IWujkjes"
|
|
},
|
|
"id": "YP79IWujkjes"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"# Single trajectory (as in the warm-up)\n",
|
|
"ms_single = sysid.ModelSequences(\n",
|
|
" \"spring_mass\", spec, \"traj_0\", initial_state, control_ts, sensor_ts,\n",
|
|
")\n",
|
|
"print(f\"Single trajectory: {ms_single.sequence_name}\")\n",
|
|
"\n",
|
|
"# Multiple trajectories for the same spec -- just pass lists\n",
|
|
"ms_multi = sysid.ModelSequences(\n",
|
|
" \"spring_mass\", spec,\n",
|
|
" [\"traj_0\", \"traj_1\"], # names\n",
|
|
" [initial_state, initial_state], # initial states\n",
|
|
" [control_ts, control_ts], # controls\n",
|
|
" [sensor_ts, sensor_ts], # sensor data\n",
|
|
")\n",
|
|
"print(f\"Multiple trajectories: {ms_multi.sequence_name}\")"
|
|
],
|
|
"metadata": {
|
|
"id": "mGaDg60Zkuiz"
|
|
},
|
|
"id": "mGaDg60Zkuiz",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"<a name=\"robot-arm\"></a>\n",
|
|
"# 4. Robot Arm: Identifying Joint Armature\n",
|
|
"\n",
|
|
"Now we tackle a realistic problem. A 5-DOF robot arm is driven by motor actuators that apply joint torques, and the armature is unknown. Armature represents reflected rotor inertia through the gear train. It is a common source of sim-to-real gap. Getting it wrong means the simulated arm accelerates too fast or too slow under the same applied torque. You can read more about it [here](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-joint-armature)."
|
|
],
|
|
"metadata": {
|
|
"id": "yTLBbv4BZMK5"
|
|
},
|
|
"id": "yTLBbv4BZMK5"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Arm model\n",
|
|
"\n",
|
|
"Five hinge joints with motor actuators (torque control). Joint damping\n",
|
|
"provides passive dissipation. The true armature values decrease from base\n",
|
|
"to tip, reflecting smaller motors on distal joints. Position sensors on every joint."
|
|
],
|
|
"metadata": {
|
|
"id": "nfGPT2f2ZPFh"
|
|
},
|
|
"id": "nfGPT2f2ZPFh"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title ARM_XML { vertical-output: true}\n",
|
|
"ARM_XML = \"\"\"\\\n",
|
|
"<mujoco model=\"arm\">\n",
|
|
" <compiler angle=\"radian\" autolimits=\"true\"/>\n",
|
|
" <option integrator=\"implicitfast\" timestep=\"0.002\">\n",
|
|
" <flag contact=\"disable\"/>\n",
|
|
" </option>\n",
|
|
" <worldbody>\n",
|
|
" <body name=\"link1\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0.05\" mass=\"1.0\" diaginertia=\"0.01 0.01 0.005\"/>\n",
|
|
" <joint name=\"joint1\" type=\"hinge\" axis=\"0 0 1\" range=\"-3.14 3.14\"\n",
|
|
" armature=\"0.5\" damping=\"1.0\"/>\n",
|
|
" <geom type=\"capsule\" fromto=\"0 0 0 0 0 0.1\" size=\"0.04\"/>\n",
|
|
" <body name=\"link2\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0.05\" mass=\"0.8\" diaginertia=\"0.008 0.008 0.004\"/>\n",
|
|
" <joint name=\"joint2\" type=\"hinge\" axis=\"0 1 0\" range=\"-3.14 3.14\"\n",
|
|
" armature=\"0.4\" damping=\"0.8\"/>\n",
|
|
" <geom type=\"capsule\" fromto=\"0 0 0 0 0 0.1\" size=\"0.035\"/>\n",
|
|
" <body name=\"link3\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0.05\" mass=\"0.6\" diaginertia=\"0.006 0.006 0.003\"/>\n",
|
|
" <joint name=\"joint3\" type=\"hinge\" axis=\"0 1 0\" range=\"-3.14 3.14\"\n",
|
|
" armature=\"0.3\" damping=\"0.6\"/>\n",
|
|
" <geom type=\"capsule\" fromto=\"0 0 0 0 0 0.1\" size=\"0.03\"/>\n",
|
|
" <body name=\"link4\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0.04\" mass=\"0.4\" diaginertia=\"0.004 0.004 0.002\"/>\n",
|
|
" <joint name=\"joint4\" type=\"hinge\" axis=\"0 0 1\" range=\"-3.14 3.14\"\n",
|
|
" armature=\"0.2\" damping=\"0.4\"/>\n",
|
|
" <geom type=\"capsule\" fromto=\"0 0 0 0 0 0.08\" size=\"0.025\"/>\n",
|
|
" <body name=\"link5\" pos=\"0 0 0.08\">\n",
|
|
" <inertial pos=\"0 0 0.03\" mass=\"0.2\" diaginertia=\"0.002 0.002 0.001\"/>\n",
|
|
" <joint name=\"joint5\" type=\"hinge\" axis=\"0 1 0\" range=\"-3.14 3.14\"\n",
|
|
" armature=\"0.1\" damping=\"0.2\"/>\n",
|
|
" <geom type=\"capsule\" fromto=\"0 0 0 0 0 0.06\" size=\"0.02\"/>\n",
|
|
" </body>\n",
|
|
" </body>\n",
|
|
" </body>\n",
|
|
" </body>\n",
|
|
" </body>\n",
|
|
" </worldbody>\n",
|
|
" <actuator>\n",
|
|
" <motor name=\"act1\" joint=\"joint1\"/>\n",
|
|
" <motor name=\"act2\" joint=\"joint2\"/>\n",
|
|
" <motor name=\"act3\" joint=\"joint3\"/>\n",
|
|
" <motor name=\"act4\" joint=\"joint4\"/>\n",
|
|
" <motor name=\"act5\" joint=\"joint5\"/>\n",
|
|
" </actuator>\n",
|
|
" <sensor>\n",
|
|
" <jointpos name=\"joint1_pos\" joint=\"joint1\"/>\n",
|
|
" <jointpos name=\"joint2_pos\" joint=\"joint2\"/>\n",
|
|
" <jointpos name=\"joint3_pos\" joint=\"joint3\"/>\n",
|
|
" <jointpos name=\"joint4_pos\" joint=\"joint4\"/>\n",
|
|
" <jointpos name=\"joint5_pos\" joint=\"joint5\"/>\n",
|
|
" </sensor>\n",
|
|
"</mujoco>\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"JOINT_NAMES = [\"joint1\", \"joint2\", \"joint3\", \"joint4\", \"joint5\"]\n",
|
|
"TRUE_ARMATURE = {\"joint1\": 0.5, \"joint2\": 0.4, \"joint3\": 0.3,\n",
|
|
" \"joint4\": 0.2, \"joint5\": 0.1}"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "hdLji2qelNqi"
|
|
},
|
|
"id": "hdLji2qelNqi",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Generate measured data\n",
|
|
"\n",
|
|
"We simulate the true model and add sensor noise to mimic real encoder\n",
|
|
"readings. A multi-frequency torque excitation ensures each joint is\n",
|
|
"well-excited. With motor actuators the control signal **is** the torque,\n",
|
|
"so there is no feedback loop to mask the effect of armature."
|
|
],
|
|
"metadata": {
|
|
"id": "UtAAiNGIlQo-"
|
|
},
|
|
"id": "UtAAiNGIlQo-"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"spec = mujoco.MjSpec.from_string(ARM_XML)\n",
|
|
"model = spec.compile()\n",
|
|
"data = mujoco.MjData(model)\n",
|
|
"\n",
|
|
"duration = 2.0\n",
|
|
"n_steps = int(duration / model.opt.timestep)\n",
|
|
"t = np.arange(n_steps) * model.opt.timestep\n",
|
|
"\n",
|
|
"# Sinusoidal torques with different frequency and amplitude per joint\n",
|
|
"ctrl = np.column_stack([\n",
|
|
" 5.0 * np.sin(2 * np.pi * 0.5 * t),\n",
|
|
" 4.0 * np.sin(2 * np.pi * 0.7 * t + 0.5),\n",
|
|
" 3.0 * np.sin(2 * np.pi * 0.4 * t + 1.0),\n",
|
|
" 2.0 * np.sin(2 * np.pi * 0.9 * t + 1.5),\n",
|
|
" 1.0 * np.sin(2 * np.pi * 0.6 * t + 2.0),\n",
|
|
"])\n",
|
|
"\n",
|
|
"initial_state = sysid.create_initial_state(model, data.qpos, data.qvel, data.act)\n",
|
|
"state, sensor = rollout.rollout(model, data, initial_state, ctrl[:-1])\n",
|
|
"state = np.squeeze(state, axis=0)\n",
|
|
"sensor = np.squeeze(sensor, axis=0)\n",
|
|
"times = state[:, 0]\n",
|
|
"\n",
|
|
"# Add realistic sensor noise\n",
|
|
"rng = np.random.default_rng(seed=0)\n",
|
|
"noise_std = np.zeros(sensor.shape[1])\n",
|
|
"noise_std[:] = 0.6 # position noise (rad)\n",
|
|
"sensor_noisy = sensor + rng.normal(scale=noise_std, size=sensor.shape)\n",
|
|
"\n",
|
|
"control_ts = sysid.TimeSeries(t, ctrl)\n",
|
|
"sensor_ts = sysid.TimeSeries.from_names(times, sensor_noisy, model)\n",
|
|
"\n",
|
|
"print(f\"Sensor channels: {sensor.shape[1]} \"\n",
|
|
" f\"({model.nsensor} sensors: 5 pos + 5 vel)\")\n",
|
|
"print(f\"Timesteps: {len(times)} ({duration}s at dt={model.opt.timestep})\")\n",
|
|
"print(f\"Noise std: {noise_std[:5][0]:.0e} rad (pos)\")"
|
|
],
|
|
"metadata": {
|
|
"id": "zTSTluXplSRm"
|
|
},
|
|
"id": "zTSTluXplSRm",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Define armature parameters\n",
|
|
"\n",
|
|
"One scalar parameter per joint. The modifier callback sets the joint's `armature` attribute on the spec. We start from a wrong initial guess of 0.01 for all joints, which is up to **50x too small** for the base joint."
|
|
],
|
|
"metadata": {
|
|
"id": "KxiNscAnlQm5"
|
|
},
|
|
"id": "KxiNscAnlQm5"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"INIT_ARMATURE = 0.01\n",
|
|
"\n",
|
|
"def make_armature_modifier(joint_name):\n",
|
|
" \"\"\"Create a modifier that sets armature on a specific joint.\"\"\"\n",
|
|
" def modifier(spec, param):\n",
|
|
" spec.joint(joint_name).armature = param.value[0]\n",
|
|
" return modifier\n",
|
|
"\n",
|
|
"params = sysid.ParameterDict()\n",
|
|
"for name in JOINT_NAMES:\n",
|
|
" true_val = TRUE_ARMATURE[name]\n",
|
|
" params.add(sysid.Parameter(\n",
|
|
" f\"{name}_armature\",\n",
|
|
" nominal=true_val,\n",
|
|
" min_value=0.01,\n",
|
|
" max_value=0.6,\n",
|
|
" modifier=make_armature_modifier(name),\n",
|
|
" ))\n",
|
|
" # Start from wrong initial guess\n",
|
|
" params[f\"{name}_armature\"].value[:] = INIT_ARMATURE\n",
|
|
"\n",
|
|
"print(\"Initial parameter vector:\", params.as_vector())\n",
|
|
"print(\"True values: \",\n",
|
|
" np.array([TRUE_ARMATURE[n] for n in JOINT_NAMES]))"
|
|
],
|
|
"metadata": {
|
|
"id": "_98Rw6mOlUsP"
|
|
},
|
|
"id": "_98Rw6mOlUsP",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Optimize"
|
|
],
|
|
"metadata": {
|
|
"id": "BDJWVxGwlQlD"
|
|
},
|
|
"id": "BDJWVxGwlQlD"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title { vertical-output: true}\n",
|
|
"ms = sysid.ModelSequences(\n",
|
|
" \"arm\", spec, \"sinusoidal\", initial_state, control_ts, sensor_ts,\n",
|
|
")\n",
|
|
"\n",
|
|
"residual_fn = sysid.build_residual_fn(models_sequences=[ms])\n",
|
|
"\n",
|
|
"opt_params, opt_result = sysid.optimize(\n",
|
|
" initial_params=params,\n",
|
|
" residual_fn=residual_fn,\n",
|
|
" optimizer=\"mujoco\",\n",
|
|
")"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "VXQoNpmolXju"
|
|
},
|
|
"id": "VXQoNpmolXju",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"#### Report\n",
|
|
"\n",
|
|
"**Confidence Internals.** Because the measured data contains sensor noise, the optimal residuals have variance, which can be used to estimate 95% parameter confidence intervals. These are displayed in the report \"Parameter Distribution\" section."
|
|
],
|
|
"metadata": {
|
|
"id": "RLB285HqZbNj"
|
|
},
|
|
"id": "RLB285HqZbNj"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"report = sysid.default_report(\n",
|
|
" models_sequences=[ms],\n",
|
|
" initial_params=params,\n",
|
|
" opt_params=opt_params,\n",
|
|
" residual_fn=residual_fn,\n",
|
|
" opt_result=opt_result,\n",
|
|
" title=\"Robot Arm Armature Identification\",\n",
|
|
" generate_videos=False,\n",
|
|
")\n",
|
|
"display_report(report)"
|
|
],
|
|
"metadata": {
|
|
"id": "98447uDUZcgI"
|
|
},
|
|
"id": "98447uDUZcgI",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Rendered overlay: initial vs. optimized vs. ground truth\n",
|
|
"\n",
|
|
"`render_rollout` takes a list of models and a batch of state trajectories\n",
|
|
"and renders them into a single scene, which lets us visually compare how\n",
|
|
"different parameter values affect the motion. We render two side-by-side\n",
|
|
"videos:\n",
|
|
"- **Before:** initial guess (red) vs. ground truth (green)\n",
|
|
"- **After:** optimized (blue) vs. ground truth (green)"
|
|
],
|
|
"metadata": {
|
|
"id": "bhesmm6Nln98"
|
|
},
|
|
"id": "bhesmm6Nln98"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title { vertical-output: true}\n",
|
|
"def set_body_rgba(body, rgba):\n",
|
|
" \"\"\"Recursively set rgba on all geoms in a body tree.\"\"\"\n",
|
|
" for geom in body.geoms:\n",
|
|
" geom.rgba = rgba\n",
|
|
" for child in body.bodies:\n",
|
|
" set_body_rgba(child, rgba)\n",
|
|
"\n",
|
|
"def make_colored_model(base_spec, rgba, armature_values):\n",
|
|
" \"\"\"Copy spec, set geom colors and armature, compile.\"\"\"\n",
|
|
" s = base_spec.copy()\n",
|
|
" for name, val in armature_values.items():\n",
|
|
" s.joint(name).armature = val\n",
|
|
" set_body_rgba(s.worldbody, rgba)\n",
|
|
" return s.compile()\n",
|
|
"\n",
|
|
"true_armature = {n: TRUE_ARMATURE[n] for n in JOINT_NAMES}\n",
|
|
"init_armature = {n: INIT_ARMATURE for n in JOINT_NAMES}\n",
|
|
"opt_armature = {n: opt_params[f\"{n}_armature\"].value[0] for n in JOINT_NAMES}\n",
|
|
"\n",
|
|
"green = [0.2, 0.8, 0.2, 0.7]\n",
|
|
"red = [1.0, 0.2, 0.2, 0.7]\n",
|
|
"blue = [0.2, 0.4, 1.0, 0.7]\n",
|
|
"\n",
|
|
"truth_model = make_colored_model(spec, green, true_armature)\n",
|
|
"init_model = make_colored_model(spec, red, init_armature)\n",
|
|
"opt_model = make_colored_model(spec, blue, opt_armature)\n",
|
|
"\n",
|
|
"fps = 30\n",
|
|
"\n",
|
|
"# Before: initial (red) vs ground truth (green)\n",
|
|
"models_before = [init_model, truth_model]\n",
|
|
"datas_before = [mujoco.MjData(m) for m in models_before]\n",
|
|
"state_before, _ = rollout.rollout(\n",
|
|
" models_before, datas_before, initial_state, ctrl[:-1]\n",
|
|
")\n",
|
|
"frames_before = sysid.render_rollout(\n",
|
|
" models_before, datas_before[0], state_before,\n",
|
|
" framerate=fps, height=400, width=560,\n",
|
|
")\n",
|
|
"\n",
|
|
"# After: optimized (blue) vs ground truth (green)\n",
|
|
"models_after = [opt_model, truth_model]\n",
|
|
"datas_after = [mujoco.MjData(m) for m in models_after]\n",
|
|
"state_after, _ = rollout.rollout(\n",
|
|
" models_after, datas_after, initial_state, ctrl[:-1]\n",
|
|
")\n",
|
|
"frames_after = sysid.render_rollout(\n",
|
|
" models_after, datas_after[0], state_after,\n",
|
|
" framerate=fps, height=400, width=560,\n",
|
|
")\n",
|
|
"\n",
|
|
"# Side by side\n",
|
|
"media.show_videos(\n",
|
|
" [frames_before, frames_after],\n",
|
|
" fps=fps,\n",
|
|
" titles=[\"Before: initial (red) vs truth (green)\",\n",
|
|
" \"After: optimized (blue) vs truth (green)\"],\n",
|
|
")"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "SkpnM_zTlp5t"
|
|
},
|
|
"id": "SkpnM_zTlp5t",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"<a name=\"ambiguity\"></a>\n",
|
|
"# 4. Parameter Identifiability\n",
|
|
"\n",
|
|
"Sometimes a single experiment cannot uniquely determine all parameters. This is related to the concept of [Structural Identifiability](https://en.wikipedia.org/wiki/Structural_identifiability).\n",
|
|
"\n",
|
|
"Consider a cart driven by a motor:\n",
|
|
"\n",
|
|
"$$m\\,\\ddot x = b\\,u(t)$$\n",
|
|
"\n",
|
|
"where $m$ is the cart mass and $b$ is the torque constant ([`gear`](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-general-gear)). The response\n",
|
|
"depends **only on the ratio** $b/m$. Doubling both $b$ and $m$ produces an\n",
|
|
"identical trajectory. With a single recording, the individual values are\n",
|
|
"fundamentally unidentifiable.\n",
|
|
"\n",
|
|
"We're going to visualize the ambiguity and resolve it by optimizing over a **second trajectory** recorded with a known mass perturbation."
|
|
],
|
|
"metadata": {
|
|
"id": "Z_LOdz7CZqlg"
|
|
},
|
|
"id": "Z_LOdz7CZqlg"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title CART_XML { vertical-output: true}\n",
|
|
"CART_XML = \"\"\"\\\n",
|
|
"<mujoco model=\"cart\">\n",
|
|
" <option timestep=\"0.002\">\n",
|
|
" <flag contact=\"disable\"/>\n",
|
|
" </option>\n",
|
|
" <worldbody>\n",
|
|
" <body name=\"cart\" pos=\"0 0 0.1\">\n",
|
|
" <inertial pos=\"0 0 0\" mass=\"2.0\" diaginertia=\"0.001 0.001 0.001\"/>\n",
|
|
" <joint name=\"slide\" type=\"slide\" axis=\"1 0 0\"/>\n",
|
|
" <geom type=\"sphere\" size=\"0.05\"/>\n",
|
|
" </body>\n",
|
|
" </worldbody>\n",
|
|
" <actuator>\n",
|
|
" <motor name=\"motor\" joint=\"slide\" gear=\"3.0\"/>\n",
|
|
" </actuator>\n",
|
|
" <sensor>\n",
|
|
" <jointpos name=\"position\" joint=\"slide\"/>\n",
|
|
" <jointvel name=\"velocity\" joint=\"slide\"/>\n",
|
|
" </sensor>\n",
|
|
"</mujoco>\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"TRUE_MASS = 2.0\n",
|
|
"TRUE_GEAR = 3.0\n",
|
|
"PAYLOAD_MASS = 1.0\n",
|
|
"\n",
|
|
"# Base cart\n",
|
|
"spec_base = mujoco.MjSpec.from_string(CART_XML)\n",
|
|
"\n",
|
|
"# Payload variant: add a rigid mass to the cart body\n",
|
|
"spec_pay = mujoco.MjSpec.from_string(CART_XML)\n",
|
|
"payload = spec_pay.body(\"cart\").add_body()\n",
|
|
"payload.name = \"payload\"\n",
|
|
"payload.mass = PAYLOAD_MASS\n",
|
|
"payload.inertia = [0.001, 0.001, 0.001]"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "eqgSVuaWZrp6"
|
|
},
|
|
"id": "eqgSVuaWZrp6",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Generate data for both configurations"
|
|
],
|
|
"metadata": {
|
|
"id": "1bfX4jpOZuLY"
|
|
},
|
|
"id": "1bfX4jpOZuLY"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title generate_data() { vertical-output: true}\n",
|
|
"def generate_data(spec, duration=0.6):\n",
|
|
" \"\"\"Rollout a spec and return (control_ts, sensor_ts, initial_state).\"\"\"\n",
|
|
" model = spec.compile()\n",
|
|
" data = mujoco.MjData(model)\n",
|
|
"\n",
|
|
" n_steps = int(duration / model.opt.timestep)\n",
|
|
" t = np.arange(n_steps) * model.opt.timestep\n",
|
|
"\n",
|
|
" ctrl = (3.0 * np.sin(2 * np.pi * 2.0 * t)\n",
|
|
" + 2.0 * np.sin(2 * np.pi * 5.0 * t)).reshape(-1, 1)\n",
|
|
"\n",
|
|
" initial_state = sysid.create_initial_state(\n",
|
|
" model, data.qpos, data.qvel, data.act\n",
|
|
" )\n",
|
|
" state, sensor = rollout.rollout(model, data, initial_state, ctrl[:-1])\n",
|
|
" state = np.squeeze(state, axis=0)\n",
|
|
" sensor = np.squeeze(sensor, axis=0)\n",
|
|
" times = state[:, 0]\n",
|
|
"\n",
|
|
" control_ts = sysid.TimeSeries(t, ctrl)\n",
|
|
" sensor_ts = sysid.TimeSeries.from_names(times, sensor, model)\n",
|
|
" return control_ts, sensor_ts, initial_state\n",
|
|
"\n",
|
|
"\n",
|
|
"ctrl_base, sens_base, state0_base = generate_data(spec_base)\n",
|
|
"ctrl_pay, sens_pay, state0_pay = generate_data(spec_pay)"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "VnFVQDLUZtfH"
|
|
},
|
|
"id": "VnFVQDLUZtfH",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Define parameters: mass and gear"
|
|
],
|
|
"metadata": {
|
|
"id": "L_o821XHZwZm"
|
|
},
|
|
"id": "L_o821XHZwZm"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"def set_cart_mass(spec, param):\n",
|
|
" spec.body(\"cart\").mass = param.value[0]\n",
|
|
"\n",
|
|
"def set_gear_ratio(spec, param):\n",
|
|
" spec.actuator(\"motor\").gear[0] = param.value[0]\n",
|
|
"\n",
|
|
"def make_params(mass_init, gear_init):\n",
|
|
" \"\"\"Create a ParameterDict with given starting values.\"\"\"\n",
|
|
" params = sysid.ParameterDict()\n",
|
|
" params.add(sysid.Parameter(\n",
|
|
" \"mass\", nominal=TRUE_MASS, min_value=0.5, max_value=5.0,\n",
|
|
" modifier=set_cart_mass,\n",
|
|
" ))\n",
|
|
" params.add(sysid.Parameter(\n",
|
|
" \"gear\", nominal=TRUE_GEAR, min_value=0.5, max_value=8.0,\n",
|
|
" modifier=set_gear_ratio,\n",
|
|
" ))\n",
|
|
" params[\"mass\"].value[:] = mass_init\n",
|
|
" params[\"gear\"].value[:] = gear_init\n",
|
|
" return params"
|
|
],
|
|
"metadata": {
|
|
"id": "KTomDxK3ZxQA"
|
|
},
|
|
"id": "KTomDxK3ZxQA",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Attempt 1: single sequence (base cart only)\n",
|
|
"\n",
|
|
"We start at $m = 3.5,\\; b = 5.25$, which gives the correct ratio\n",
|
|
"$b/m = 1.5$ but wrong individual values. Since the cost is exactly zero\n",
|
|
"everywhere along $b/m = 1.5$, the optimizer has no gradient to follow."
|
|
],
|
|
"metadata": {
|
|
"id": "jmbqF09FZzLc"
|
|
},
|
|
"id": "jmbqF09FZzLc"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"params_1seq = make_params(mass_init=3.5, gear_init=5.25)\n",
|
|
"\n",
|
|
"ms_base = sysid.ModelSequences(\n",
|
|
" \"cart\", spec_base, \"base_traj\", state0_base, ctrl_base, sens_base,\n",
|
|
")\n",
|
|
"\n",
|
|
"residual_fn_1seq = sysid.build_residual_fn(models_sequences=[ms_base])\n",
|
|
"\n",
|
|
"opt_1seq, result_1seq = sysid.optimize(\n",
|
|
" initial_params=params_1seq,\n",
|
|
" residual_fn=residual_fn_1seq,\n",
|
|
" optimizer=\"mujoco\",\n",
|
|
")\n",
|
|
"\n",
|
|
"print(f\"\\n--- Single sequence ---\")\n",
|
|
"print(f\" mass: {opt_1seq['mass'].value[0]:.4f} (true: {TRUE_MASS})\")\n",
|
|
"print(f\" gear: {opt_1seq['gear'].value[0]:.4f} (true: {TRUE_GEAR})\")\n",
|
|
"print(f\" b/m: {opt_1seq['gear'].value[0] / opt_1seq['mass'].value[0]:.4f}\"\n",
|
|
" f\" (true: {TRUE_GEAR / TRUE_MASS:.4f})\")"
|
|
],
|
|
"metadata": {
|
|
"id": "8ZSKfzE2Z0eQ"
|
|
},
|
|
"id": "8ZSKfzE2Z0eQ",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"The optimizer converged instantly to the **wrong** individual values!\n",
|
|
"The ratio $b/m$ is correct, but the optimizer has no way to determine $m$ and\n",
|
|
"$b$ separately."
|
|
],
|
|
"metadata": {
|
|
"id": "yJT6PmkBZ1hy"
|
|
},
|
|
"id": "yJT6PmkBZ1hy"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Attempt 2: two sequences (base + 1 kg payload)\n",
|
|
"\n",
|
|
"Adding a second trajectory with a **known 1.0 kg payload** gives the\n",
|
|
"optimizer a second equation:\n",
|
|
"- Base: acceleration $= b / m$\n",
|
|
"- Payload: acceleration $= b / (m + 1)$\n",
|
|
"\n",
|
|
"Two equations, two unknowns."
|
|
],
|
|
"metadata": {
|
|
"id": "mccbM8lmZ4O5"
|
|
},
|
|
"id": "mccbM8lmZ4O5"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"params_2seq = make_params(mass_init=3.5, gear_init=5.25)\n",
|
|
"\n",
|
|
"ms_payload = sysid.ModelSequences(\n",
|
|
" \"cart_payload\", spec_pay, \"payload_traj\", state0_pay, ctrl_pay, sens_pay,\n",
|
|
")\n",
|
|
"\n",
|
|
"residual_fn_2seq = sysid.build_residual_fn(\n",
|
|
" models_sequences=[ms_base, ms_payload],\n",
|
|
")\n",
|
|
"\n",
|
|
"opt_2seq, result_2seq = sysid.optimize(\n",
|
|
" initial_params=params_2seq,\n",
|
|
" residual_fn=residual_fn_2seq,\n",
|
|
" optimizer=\"mujoco\",\n",
|
|
")\n",
|
|
"\n",
|
|
"print(f\"\\n--- Two sequences (base + payload) ---\")\n",
|
|
"print(f\" mass: {opt_2seq['mass'].value[0]:.4f} (true: {TRUE_MASS})\")\n",
|
|
"print(f\" gear: {opt_2seq['gear'].value[0]:.4f} (true: {TRUE_GEAR})\")\n",
|
|
"print(f\" b/m: {opt_2seq['gear'].value[0] / opt_2seq['mass'].value[0]:.4f}\"\n",
|
|
" f\" (true: {TRUE_GEAR / TRUE_MASS:.4f})\")"
|
|
],
|
|
"metadata": {
|
|
"id": "lC2gdzR1Z5LS"
|
|
},
|
|
"id": "lC2gdzR1Z5LS",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Visualize the cost landscape\n",
|
|
"\n",
|
|
"Let's evaluate the cost on a grid of $(m, b)$ values to see the degeneracy."
|
|
],
|
|
"metadata": {
|
|
"id": "0guywON0Z6aS"
|
|
},
|
|
"id": "0guywON0Z6aS"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title compute_cost_surface() { vertical-output: true}\n",
|
|
"mass_grid = np.linspace(0.6, 4.5, 35)\n",
|
|
"gear_grid = np.linspace(0.6, 7.5, 35)\n",
|
|
"\n",
|
|
"def compute_cost_surface(residual_fn, params_template):\n",
|
|
" \"\"\"Evaluate cost on a (mass, gear) grid.\"\"\"\n",
|
|
" cost = np.zeros((len(mass_grid), len(gear_grid)))\n",
|
|
" p = params_template.copy()\n",
|
|
" for i, m in enumerate(mass_grid):\n",
|
|
" for j, g in enumerate(gear_grid):\n",
|
|
" x = np.array([m, g])\n",
|
|
" res, _, _ = residual_fn(x, p)\n",
|
|
" cost[i, j] = sum(np.sum(r**2) for r in res)\n",
|
|
" return cost\n",
|
|
"\n",
|
|
"cost_1seq = compute_cost_surface(residual_fn_1seq, params_1seq)\n",
|
|
"cost_2seq = compute_cost_surface(residual_fn_2seq, params_2seq)"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "V1l3YsFiZ7Z_"
|
|
},
|
|
"id": "V1l3YsFiZ7Z_",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"### Side-by-side cost landscapes"
|
|
],
|
|
"metadata": {
|
|
"id": "4zh6A4CTZ-NB"
|
|
},
|
|
"id": "4zh6A4CTZ-NB"
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"source": [
|
|
"#@title { vertical-output: true}\n",
|
|
"G, M = np.meshgrid(gear_grid, mass_grid)\n",
|
|
"\n",
|
|
"# Compute log cost with shared color range across both panels\n",
|
|
"log_cost_1 = np.log10(cost_1seq + 1e-12)\n",
|
|
"log_cost_2 = np.log10(cost_2seq + 1e-12)\n",
|
|
"vmin = min(log_cost_1.min(), log_cost_2.min())\n",
|
|
"vmax = max(log_cost_1.max(), log_cost_2.max())\n",
|
|
"levels = np.linspace(vmin, vmax, 30)\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True,\n",
|
|
" layout=\"constrained\")\n",
|
|
"\n",
|
|
"for ax, log_cost, title, opt_p in [\n",
|
|
" (axes[0], log_cost_1, \"Single sequence\", opt_1seq),\n",
|
|
" (axes[1], log_cost_2, \"Two sequences (base + payload)\", opt_2seq),\n",
|
|
"]:\n",
|
|
" cf = ax.contourf(G, M, log_cost, levels=levels, cmap=\"viridis\")\n",
|
|
"\n",
|
|
" ax.plot(TRUE_GEAR, TRUE_MASS, \"r*\", markersize=15, label=\"True\", zorder=5)\n",
|
|
" ax.plot(opt_p[\"gear\"].value[0], opt_p[\"mass\"].value[0],\n",
|
|
" marker=\"X\", color=\"gold\", markeredgecolor=\"k\", markeredgewidth=1,\n",
|
|
" markersize=12, linestyle=\"none\", label=\"Optimized\", zorder=5)\n",
|
|
"\n",
|
|
" m_line = np.linspace(0.5, 5.0, 200)\n",
|
|
" ax.plot(1.5 * m_line, m_line, \"r--\", alpha=0.5, lw=1, label=\"b/m = 1.5\")\n",
|
|
"\n",
|
|
" ax.set_xlabel(\"Gear ratio (b)\")\n",
|
|
" ax.set_title(title)\n",
|
|
" ax.grid(True, alpha=0.2)\n",
|
|
"\n",
|
|
"axes[0].set_ylabel(\"Mass (m)\")\n",
|
|
"\n",
|
|
"# Shared colorbar\n",
|
|
"fig.colorbar(cf, ax=axes, label=r\"$\\log_{10}$(cost)\", shrink=0.9)\n",
|
|
"\n",
|
|
"# Single legend below both plots\n",
|
|
"handles, labels = axes[0].get_legend_handles_labels()\n",
|
|
"fig.legend(handles, labels, loc=\"outside lower center\", ncol=3, fontsize=9)\n",
|
|
"\n",
|
|
"plt.show()"
|
|
],
|
|
"metadata": {
|
|
"cellView": "form",
|
|
"id": "vK2UVNnEZ_BW"
|
|
},
|
|
"id": "vK2UVNnEZ_BW",
|
|
"execution_count": null,
|
|
"outputs": []
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"**Left:** With a single sequence, the cost is **exactly zero** along the entire\n",
|
|
"$b/m = 1.5$ line. The optimizer stays at its starting point because there is no\n",
|
|
"gradient to follow. Mass and gear are fundamentally unidentifiable.\n",
|
|
"\n",
|
|
"**Right:** Adding the payload trajectory collapses the valley into a localized\n",
|
|
"minimum at the true parameter values $(m=2, b=3)$.\n",
|
|
"\n",
|
|
"**Takeaway:** It is easy to create models with unidentifiable parameters. When this is the case, adding diverse excitations and structural perturbations (e.g., added mass) can resolve it."
|
|
],
|
|
"metadata": {
|
|
"id": "siTyueLpaAGi"
|
|
},
|
|
"id": "siTyueLpaAGi"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"source": [
|
|
"# Conclusion\n",
|
|
"\n",
|
|
"We hope you enjoyed this introduction to system identification with MuJoCo.\n",
|
|
"The library has additional features not covered here, including:\n",
|
|
"\n",
|
|
"- **[Physically plausible inertia parameterization](https://ieeexplore.ieee.org/document/9690029)**\n",
|
|
" for identifying mass, center of mass, and rotational inertia while\n",
|
|
" guaranteeing the result is physically valid\n",
|
|
"- **Per-sensor weighting** to emphasize certain sensors in the cost\n",
|
|
"- **Robust loss functions** (Huber, Cauchy, etc.) for handling outliers in\n",
|
|
" measured data (see the\n",
|
|
" [Least Squares](https://colab.research.google.com/github/google-deepmind/mujoco/blob/main/python/least_squares.ipynb)\n",
|
|
" notebook for details on non-quadratic norms)\n",
|
|
"- **Multiple optimizer backends** (MuJoCo native, scipy, scipy with parallel\n",
|
|
" finite differences)\n",
|
|
"\n",
|
|
"For a comprehensive survey of the field, see\n",
|
|
"[Robot Model Identification and Learning: A Modern Perspective](https://www.annualreviews.org/content/journals/10.1146/annurev-control-061523-102310)\n",
|
|
"(Annual Review of Control, Robotics, and Autonomous Systems, 2024)."
|
|
],
|
|
"metadata": {
|
|
"id": "OggQL1VemwMV"
|
|
},
|
|
"id": "OggQL1VemwMV"
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"codemirror_mode": {
|
|
"name": "ipython",
|
|
"version": 3
|
|
},
|
|
"file_extension": ".py",
|
|
"mimetype": "text/x-python",
|
|
"name": "python",
|
|
"nbconvert_exporter": "python",
|
|
"pygments_lexer": "ipython3",
|
|
"version": "3.13.7"
|
|
},
|
|
"colab": {
|
|
"provenance": [],
|
|
"gpuType": "T4",
|
|
"collapsed_sections": [
|
|
"8P1e2JXckIbW",
|
|
"_7k6iikqkIP6",
|
|
"nfGPT2f2ZPFh",
|
|
"UtAAiNGIlQo-"
|
|
],
|
|
"toc_visible": true
|
|
},
|
|
"accelerator": "GPU"
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 5
|
|
}
|