{ "cells": [ { "cell_type": "markdown", "id": "b6d3405a", "metadata": {}, "source": [ "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tayalmanan28/MuJoCo-Tutorial/blob/main/tutorial/interactive_control.ipynb)\n", "\n", "# Interactive Control Playground\n", "\n", "Use sliders to tune PD gains in real-time and see how they affect pendulum behavior.\n", "\n", "**Requirements:** `ipywidgets` (included in Colab and JupyterLab)" ] }, { "cell_type": "code", "execution_count": null, "id": "cdcce427", "metadata": {}, "outputs": [], "source": [ "!pip install -q mujoco mediapy ipywidgets matplotlib" ] }, { "cell_type": "code", "execution_count": null, "id": "64ffebff", "metadata": {}, "outputs": [], "source": [ "import mujoco\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import mediapy as media\n", "import ipywidgets as widgets\n", "from IPython.display import display, clear_output" ] }, { "cell_type": "code", "execution_count": null, "id": "f6760ef9", "metadata": {}, "outputs": [], "source": [ "XML = \"\"\"\n", "\n", " \n", "\"\"\"\n", "\n", "model = mujoco.MjModel.from_xml_string(XML)\n", "print(f\"Model loaded: {model.nq} DOF, {model.nu} actuators\")" ] }, { "cell_type": "markdown", "id": "eb3b5252", "metadata": {}, "source": [ "## Simulate with Given Gains\n", "\n", "This function runs a simulation and returns the time-series data:" ] }, { "cell_type": "code", "execution_count": null, "id": "e7ca64af", "metadata": {}, "outputs": [], "source": [ "def simulate_pd(kp, kd, target_deg=60.0, duration=5.0):\n", " \"\"\"Run PD simulation and return time, angle, torque arrays.\"\"\"\n", " data = mujoco.MjData(model)\n", " target = np.radians(target_deg)\n", " \n", " times, angles, torques = [], [], []\n", " while data.time < duration:\n", " error = target - data.qpos[0]\n", " torque = kp * error - kd * data.qvel[0]\n", " data.ctrl[0] = np.clip(torque, -10, 10)\n", " \n", " times.append(data.time)\n", " angles.append(np.degrees(data.qpos[0]))\n", " torques.append(data.ctrl[0])\n", " mujoco.mj_step(model, data)\n", " \n", " return np.array(times), np.array(angles), np.array(torques)" ] }, { "cell_type": "markdown", "id": "ba139fcc", "metadata": {}, "source": [ "## Interactive Slider Widget\n", "\n", "Drag the sliders to change `kp`, `kd`, and target angle. The plot updates instantly!" ] }, { "cell_type": "code", "execution_count": null, "id": "79e2a17a", "metadata": {}, "outputs": [], "source": [ "output = widgets.Output()\n", "\n", "kp_slider = widgets.FloatSlider(value=10, min=0, max=50, step=0.5,\n", " description='Kp (stiffness):', style={'description_width': '120px'},\n", " layout=widgets.Layout(width='500px'))\n", "kd_slider = widgets.FloatSlider(value=2, min=0, max=20, step=0.2,\n", " description='Kd (damping):', style={'description_width': '120px'},\n", " layout=widgets.Layout(width='500px'))\n", "target_slider = widgets.FloatSlider(value=60, min=-90, max=90, step=5,\n", " description='Target (deg):', style={'description_width': '120px'},\n", " layout=widgets.Layout(width='500px'))\n", "\n", "def update_plot(change=None):\n", " kp = kp_slider.value\n", " kd = kd_slider.value\n", " target = target_slider.value\n", " \n", " times, angles, torques = simulate_pd(kp, kd, target)\n", " \n", " with output:\n", " clear_output(wait=True)\n", " fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 5), sharex=True)\n", " \n", " ax1.plot(times, angles, 'b-', linewidth=1.5)\n", " ax1.axhline(target, color='r', linestyle='--', alpha=0.7, label=f'Target = {target}°')\n", " ax1.set_ylabel('Angle (deg)')\n", " ax1.set_ylim(-100, 100)\n", " ax1.legend(loc='upper right')\n", " ax1.grid(True, alpha=0.3)\n", " ax1.set_title(f'PD Control — Kp={kp:.1f}, Kd={kd:.1f}')\n", " \n", " # Classify response\n", " overshoot = max(angles) - target if max(angles) > target else 0\n", " ss_error = abs(angles[-1] - target)\n", " ax1.text(0.02, 0.95, f'Overshoot: {overshoot:.1f}°\\nSS Error: {ss_error:.2f}°',\n", " transform=ax1.transAxes, va='top', fontsize=9,\n", " bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))\n", " \n", " ax2.plot(times, torques, 'orange', linewidth=1.5)\n", " ax2.axhline(0, color='k', linewidth=0.5)\n", " ax2.set_xlabel('Time (s)')\n", " ax2.set_ylabel('Torque (Nm)')\n", " ax2.set_ylim(-11, 11)\n", " ax2.grid(True, alpha=0.3)\n", " \n", " plt.tight_layout()\n", " plt.show()\n", "\n", "# Attach observers\n", "kp_slider.observe(update_plot, names='value')\n", "kd_slider.observe(update_plot, names='value')\n", "target_slider.observe(update_plot, names='value')\n", "\n", "# Display\n", "display(widgets.VBox([kp_slider, kd_slider, target_slider, output]))\n", "update_plot() # Initial plot" ] }, { "cell_type": "markdown", "id": "66b2bfc1", "metadata": {}, "source": [ "## Render a Video\n", "\n", "Once you've found gains you like, render a video with those settings:" ] }, { "cell_type": "code", "execution_count": null, "id": "8a49314b", "metadata": {}, "outputs": [], "source": [ "def render_pd_video(kp, kd, target_deg=60.0, duration=4.0, fps=30):\n", " \"\"\"Render a video of the pendulum with given PD gains.\"\"\"\n", " data = mujoco.MjData(model)\n", " target = np.radians(target_deg)\n", " renderer = mujoco.Renderer(model, height=300, width=400)\n", " \n", " frames = []\n", " steps_per_frame = int(1.0 / (model.opt.timestep * fps))\n", " \n", " while data.time < duration:\n", " data.ctrl[0] = np.clip(kp * (target - data.qpos[0]) - kd * data.qvel[0], -10, 10)\n", " mujoco.mj_step(model, data)\n", " \n", " if int(data.time / model.opt.timestep) % steps_per_frame == 0:\n", " renderer.update_scene(data)\n", " frames.append(renderer.render().copy())\n", " \n", " renderer.close()\n", " return frames\n", "\n", "# Use current slider values\n", "frames = render_pd_video(kp_slider.value, kd_slider.value, target_slider.value)\n", "media.show_video(frames, fps=30)" ] }, { "cell_type": "markdown", "id": "cd975d63", "metadata": {}, "source": [ "## Exercises\n", "\n", "1. **Find critically damped gains** — minimize overshoot while reaching target quickly\n", "2. **What happens with Kd=0?** — pure proportional control\n", "3. **What happens with very high Kp?** — notice the torque saturates at ±10 Nm\n", "4. **Try negative Kd** — what goes wrong? (hint: positive feedback)\n", "\n", "**Key insight:** The ratio $\\zeta = \\frac{K_d}{2\\sqrt{K_p}}$ determines damping:\n", "- $\\zeta < 1$: underdamped (oscillates)\n", "- $\\zeta = 1$: critically damped (fastest without overshoot)\n", "- $\\zeta > 1$: overdamped (slow approach)" ] } ], "metadata": { "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }